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
This commit is contained in:
Finley Ge 2025-03-12 13:47:15 +08:00 committed by archer
parent 2c7bf2548b
commit a9e5017492
No known key found for this signature in database
GPG Key ID: 4446499B846D4A9E
26 changed files with 719 additions and 251 deletions

View File

@ -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')
}
];

View File

@ -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]
}
};

View File

@ -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 }[]

View File

@ -0,0 +1 @@
export const MaxInvitationLinksAmount = 10;

View File

@ -0,0 +1,3 @@
export function isForbidden({ expires, forbidden }: { expires: Date; forbidden?: boolean }) {
return forbidden || new Date(expires) < new Date();
}

View File

@ -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<InvitationSchemaType>(
InvitationCollectionName,
InvitationSchema
);

View File

@ -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<InvitationSchemaType, 'members'> & {
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<InvitationSchemaType, 'members' | 'teamId' | '_id'>
> & {
linkId: string;
};
export type InvitationInfoType = InvitationSchemaType & {
teamAvatar: string;
teamName: string;
};

View File

@ -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 (
<Flex position="relative">
{avatars.slice(0, max).map((avatar, index) => (

View File

@ -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'),

View File

@ -0,0 +1,10 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17994_4)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.98584 2.23348C5.80108 2.23348 3.21932 4.81524 3.21932 8C3.21932 11.1848 5.80108 13.7665 8.98584 13.7665C12.1706 13.7665 14.7524 11.1848 14.7524 8C14.7524 4.81524 12.1706 2.23348 8.98584 2.23348ZM1.93787 8C1.93787 4.10751 5.09335 0.952026 8.98584 0.952026C12.8783 0.952026 16.0338 4.10751 16.0338 8C16.0338 11.8925 12.8783 15.048 8.98584 15.048C5.09335 15.048 1.93787 11.8925 1.93787 8ZM6.42294 6.07782C6.42294 5.72396 6.7098 5.4371 7.06366 5.4371H10.908C11.2619 5.4371 11.5487 5.72396 11.5487 6.07782V9.92217C11.5487 10.276 11.2619 10.5629 10.908 10.5629H7.06366C6.7098 10.5629 6.42294 10.276 6.42294 9.92217V6.07782ZM7.70439 6.71855V9.28145H10.2673V6.71855H7.70439Z" fill="#485264"/>
</g>
<defs>
<clipPath id="clip0_17994_4">
<rect width="16" height="16" fill="white" transform="translate(0.98584)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.98608 2.34313C10.5482 0.78103 13.0808 0.78103 14.6429 2.34313C16.205 3.90522 16.205 6.43788 14.6429 7.99998L13.7001 8.94279C13.4398 9.20314 13.0177 9.20314 12.7573 8.94279C12.497 8.68244 12.497 8.26033 12.7573 7.99998L13.7001 7.05717C14.7415 6.01577 14.7415 4.32733 13.7001 3.28594C12.6587 2.24454 10.9703 2.24454 9.92889 3.28594L8.98608 4.22875C8.72573 4.48909 8.30362 4.48909 8.04327 4.22875C7.78292 3.9684 7.78292 3.54629 8.04327 3.28594L8.98608 2.34313ZM11.7908 5.19523C12.0512 5.45558 12.0512 5.87769 11.7908 6.13804L7.12415 10.8047C6.8638 11.0651 6.44169 11.0651 6.18134 10.8047C5.92099 10.5444 5.92099 10.1222 6.18134 9.8619L10.848 5.19523C11.1084 4.93488 11.5305 4.93488 11.7908 5.19523ZM5.21484 7.05717C5.47519 7.31752 5.47519 7.73963 5.21484 7.99998L4.27204 8.94279C3.23064 9.98419 3.23064 11.6726 4.27204 12.714C5.31343 13.7554 7.00187 13.7554 8.04327 12.714L8.98608 11.7712C9.24643 11.5109 9.66854 11.5109 9.92889 11.7712C10.1892 12.0316 10.1892 12.4537 9.92889 12.714L8.98608 13.6568C7.42398 15.2189 4.89132 15.2189 3.32923 13.6568C1.76713 12.0947 1.76713 9.56208 3.32923 7.99998L4.27204 7.05717C4.53239 6.79682 4.9545 6.79682 5.21484 7.05717Z" fill="#485264"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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"
}

View File

@ -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",

View File

@ -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": "邀请链接已失效,请联系团队管理员"
}

View File

@ -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": "您当前身份为游客,无权操作",

View File

@ -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": "邀請連結已失效,請聯繫團隊管理員"
}

View File

@ -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": "您目前身份為訪客,無權操作",

View File

@ -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 }) => {
</Box>
{feConfigs?.isPlus && (
<>
{!!userInfo && <UpdateInviteModal />}
{notSufficientModalType && <NotSufficientModal type={notSufficientModalType} />}
{!!userInfo && <SystemMsgModal />}
{showUpdateNotification && (

View File

@ -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 (
<MyModal
isOpen={inviteList && inviteList.length > 0}
iconSrc="/imgs/modal/team.svg"
title={
<Box>
<Box>{t('common:user.team.Processing invitations')}</Box>
<Box fontWeight={'normal'} fontSize={'sm'} color={'myGray.500'}>
{t('common:user.team.Processing invitations Tips', { amount: inviteList?.length })}
</Box>
</Box>
}
maxW={['90vw', '500px']}
>
<ModalBody>
{inviteList?.map((item) => (
<Flex
key={item.teamId}
alignItems={'center'}
border={theme.borders.base}
borderRadius={'md'}
px={3}
py={2}
_notFirst={{
mt: 3
}}
>
<Avatar src={item.avatar} w={['16px', '23px']} />
<Box mx={2}>{item.teamName}</Box>
<Box flex={1} />
<Button
size="sm"
variant={'solid'}
colorScheme="green"
isLoading={isLoadingAccept}
onClick={() => {
openConfirm(
() =>
onAccept({
tmbId: item.tmbId,
status: TeamMemberStatusEnum.active
}),
undefined,
t('common:user.team.invite.Accept Confirm')
)();
}}
>
{t('common:user.team.invite.accept')}
</Button>
<Button
size="sm"
ml={2}
variant={'solid'}
colorScheme="red"
isLoading={isLoadingReject}
onClick={() => {
openConfirm(
() =>
onReject({
tmbId: item.tmbId,
status: TeamMemberStatusEnum.reject
}),
undefined,
t('common:user.team.invite.Reject Confirm')
)();
}}
>
{t('common:user.team.invite.reject')}
</Button>
</Flex>
))}
</ModalBody>
<ModalFooter justifyContent={'center'}>
<Box>{t('common:user.team.invite.Deal Width Footer Tip')}</Box>
</ModalFooter>
<ConfirmModal />
</MyModal>
);
};
export default React.memo(UpdateInviteModal);

View File

@ -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<InvitationLinkCreateType>({
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 (
<MyModal
isOpen
iconSrc="common/addLight"
iconColor="primary.500"
title={<Box>{t('account_team:create_invitation_link')}</Box>}
minW={'500px'}
>
<ModalCloseButton onClick={onClose} />
<ModalBody>
<Grid gap={4} w="full" templateColumns="max-content 1fr" alignItems="center">
<FormLabel required={true}>{t('account_team:invitation_link_description')}</FormLabel>
<Input
placeholder={t('account_team:invitation_link_description')}
{...register('description', { required: true })}
/>
<FormLabel required={true}>{t('account_team:expires')}</FormLabel>
<MySelect
list={expiresOptions}
value={expires}
onchange={(val) => setValue('expires', val)}
minW="120px"
/>
<FormLabel required={true}>{t('account_team:used_times_limit')}</FormLabel>
<MySelect
list={usedTimesLimitOptions}
value={usedTimesLimit}
onchange={(val) => setValue('usedTimesLimit', val)}
minW="120px"
/>
</Grid>
</ModalBody>
<ModalFooter>
<Button isLoading={false} onClick={onClose} variant="outline">
{t('common:common.Cancel')}
</Button>
<Button isLoading={false} onClick={handleSubmit(createInvitationLink)} ml="4">
{t('common:common.Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
}
export default CreateInvitationModal;

View File

@ -172,7 +172,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
</Td>
<Td>
{group.name === DefaultGroupName ? (
<AvatarGroup avatars={members.map((v) => v.avatar)} groupId={group._id} />
<AvatarGroup avatars={members.map((v) => v.avatar)} />
) : hasGroupManagePer(group) ? (
<MyTooltip label={t('account_team:manage_member')}>
<Box cursor="pointer" onClick={() => 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}
/>
</Box>
</MyTooltip>
@ -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}
/>
)}
</Td>

View File

@ -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 && (
<MyModal
isOpen={true}
iconSrc="support/user/usersLight"
title={t('account_team:handle_invitation')}
iconColor={'primary.600'}
>
<ModalCloseButton onClick={onClose} />
<ModalBody>
<Flex
key={invitationInfo._id}
alignItems={'center'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'md'}
px={3}
py={2}
>
<Avatar src={invitationInfo.teamAvatar} w={['16px', '23px']} />
<Box mx={2}>{invitationInfo.teamName}</Box>
<Box flex={1} />
<Button size="sm" variant={'solid'} colorScheme="green" onClick={acceptInvitation}>
{t('common:user.team.invite.accept')}
</Button>
<Button size="sm" ml={2} variant="outline" onClick={onClose}>
{t('account_team:ignore')}
</Button>
</Flex>
</ModalBody>
</MyModal>
)}
</>
);
}
export default Invite;

View File

@ -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<string[]>([]);
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,
<Box whiteSpace={'pre-wrap'}>
{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(', ')
})}
</Box>
)();
},
errorToast: t('user:team.Invite Member Failed Tip')
manual: true,
onSuccess: refetchInvitationLinkList,
successToast: t('account_team:forbid_success')
}
);
return (
<MyModal
isLoading={isLoading}
isOpen
iconSrc="common/inviteLight"
iconColor="primary.600"
minW={'600px'}
title={
<Box>
<Box>{t('common:user.team.Invite Member')}</Box>
@ -63,26 +96,177 @@ const InviteModal = ({
</Box>
</Box>
}
maxW={['90vw', '400px']}
maxW={['90vw']}
overflow={'unset'}
>
<ModalCloseButton onClick={onClose} />
<ModalBody>
<Box mb={2}>{t('common:user.Account')}</Box>
<TagTextarea defaultValues={inviteUsernames} onUpdate={setInviteUsernames} />
<ModalHeader pb="0">
<Flex alignItems={'center'} justifyContent={'space-between'} mx="2">
<HStack>
<Icon name="common/list" w="16px" />
<Box ml="6px" fontSize="md">
{t('account_team:invitation_link_list')}
</Box>
</HStack>
<Button onClick={onOpenCreate}>{t('account_team:create_invitation_link')}</Button>
</Flex>
</ModalHeader>
<ModalBody maxH="500px">
<TableContainer overflowY={'auto'}>
<Table fontSize={'sm'} overflow={'unset'}>
<Thead>
<Tr bgColor={'white !important'}>
<Th borderLeftRadius="6px" bgColor="myGray.100">
{t('account_team:invitation_link_description')}
</Th>
<Th bgColor="myGray.100">{t('account_team:expires')}</Th>
<Th bgColor="myGray.100">{t('account_team:used_times_limit')}</Th>
<Th bgColor="myGray.100">{t('account_team:invited')}</Th>
<Th bgColor="myGray.100" borderRightRadius="6px">
{t('common:common.Action')}
</Th>
</Tr>
</Thead>
{!!invitationLinkList?.length && (
<Tbody overflow={'unset'}>
{invitationLinkList?.map((item) => {
const isForbidden = item.forbidden || new Date(item.expires) < new Date();
return (
<Tr key={item._id} overflow={'unset'}>
<Td maxW="200px" minW="100px">
{item.description}
</Td>
<Td>
{isForbidden ? (
<Tag colorSchema="gray">{t('account_team:has_forbidden')}</Tag>
) : (
format(new Date(item.expires), 'yyyy-MM-dd HH:mm')
)}
</Td>
<Td>
{item.usedTimesLimit === -1
? t('account_team:unlimited')
: item.usedTimesLimit}
</Td>
<Td>
<MyPopover
w="fit-content"
Trigger={
<Box
minW="100px"
borderRadius="md"
cursor="pointer"
_hover={{ bg: 'myGray.100' }}
p="1.5"
w="fit-content"
>
<AvatarGroup max={3} avatars={item.members.map((i) => i.avatar)} />
</Box>
}
trigger="click"
closeOnBlur={true}
>
{() => (
<Box py="4" maxH="200px" w="fit-content">
<Flex mx="4" justifyContent="center" alignItems={'center'}>
<Box>{t('account_team:has_invited')}</Box>
<Box
ml="auto"
bg="myGray.200"
px="2"
borderRadius="md"
fontSize="sm"
>
{item.members.length}
</Box>
</Flex>
<Divider my="2" mx="4" />
<Grid
w="fit-content"
mt="2"
gridRowGap="4"
gridTemplateColumns="1fr 1fr"
overflow="auto"
alignItems="center"
mx="4"
>
{item.members.map((member) => (
<Box key={member.tmbId} justifySelf="start">
<MemberTag name={member.name} avatar={member.avatar} />
</Box>
))}
</Grid>
</Box>
)}
</MyPopover>
</Td>
<Td>
{!isForbidden && (
<>
<Button
size="sm"
variant="outline"
onClick={() => onCopy(item._id)}
color="myGray.900"
>
<Icon name="common/link" w="16px" mr="1" />
{t('account_team:copy_link')}
</Button>
<MyPopover
placement="bottom-end"
Trigger={
<Button variant="outline" ml="10px" size="sm" color="myGray.900">
<Icon name="common/lineStop" w="16px" mr="1" />
{t('account_team:forbidden')}
</Button>
}
closeOnBlur={true}
>
{({ onClose: onClosePopover }) => (
<Box p={4}>
<Box fontWeight={400} whiteSpace="pre-wrap">
{t('account_team:forbid_hint')}
</Box>
<Flex gap={2} mt={2} justifyContent={'flex-end'}>
<Button variant="outline" onClick={onClosePopover}>
{t('common:common.Cancel')}
</Button>
<Button
variant="outline"
colorScheme="red"
onClick={() => {
onForbid(item._id);
onClosePopover();
}}
>
{t('account_team:confirm_forbidden')}
</Button>
</Flex>
</Box>
)}
</MyPopover>
</>
)}
</Td>
</Tr>
);
})}
</Tbody>
)}
</Table>
{!invitationLinkList?.length && <EmptyTip />}
</TableContainer>
</ModalBody>
<ModalFooter>
<Button
w={'100%'}
h={'34px'}
isDisabled={inviteUsernames.length === 0}
isLoading={isLoading}
onClick={onInvite}
>
{t('user:team.Confirm Invite')}
</Button>
<ModalFooter justifyContent={'flex-start'}>
<Tag colorSchema="blue" marginBlock="2">
<Box>{t('account_team:invitation_link_auto_clean_hint')}</Box>
</Tag>
</ModalFooter>
<ConfirmModal />
{isOpenCreate && (
<CreateInvitationModal
onClose={() => Promise.all([onCloseCreate(), refetchInvitationLinkList()])}
/>
)}
</MyModal>
);
};

View File

@ -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 }) {
<Avatar src={member.avatar} w={['18px', '22px']} borderRadius={'50%'} />
<Box className={'textEllipsis'}>
{member.memberName}
{member.status === 'waiting' && (
<Tag ml="2" colorSchema="yellow">
{t('account_team:waiting')}
</Tag>
)}
{member.status === 'leave' && (
{member.status !== 'active' && (
<Tag ml="2" colorSchema="gray">
{t('account_team:leave')}
</Tag>
@ -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 ? (
<Icon
name={'common/trash'}
cursor={'pointer'}
@ -320,30 +315,28 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
}}
/>
) : (
<Icon
name={'common/confirm/restoreTip'}
cursor={'pointer'}
w="1rem"
p="1"
borderRadius="sm"
_hover={{
color: 'primary.500',
bgColor: 'myGray.100'
}}
onClick={() => {
openRestoreMember(
() =>
onRestore({
tmbId: member.tmbId,
status: TeamMemberStatusEnum.active
}),
undefined,
t('account_team:restore_tip', {
username: member.memberName
})
)();
}}
/>
member.status === TeamMemberStatusEnum.forbidden && (
<Icon
name={'common/confirm/restoreTip'}
cursor={'pointer'}
w="1rem"
p="1"
borderRadius="sm"
_hover={{
color: 'primary.500',
bgColor: 'myGray.100'
}}
onClick={() => {
openRestoreMember(
() => onRestore(member.tmbId),
undefined,
t('account_team:restore_tip', {
username: member.memberName
})
)();
}}
/>
)
))}
</Td>
</Tr>

View File

@ -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 && <PermissionManage Tabs={Tabs} />}
</Box>
</Flex>
{invitelinkid && <HandleInviteModal invitelinkid={invitelinkid} />}
</AccountContainer>
);
};

View File

@ -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<PaginationResponse<TeamMemberItemType>>(`/proApi/support/user/team/member/list`, props);
export const postInviteTeamMember = (data: InviteMemberProps) =>
POST<InviteMemberResponse>(`/proApi/support/user/team/member/invite`, data);
// export const postInviteTeamMember = (data: InviteMemberProps) =>
// POST<InviteMemberResponse>(`/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<string>(`/proApi/support/user/team/invitationLink/create`, data);
export const getInvitationLinkList = () =>
GET<InvitationType[]>(`/proApi/support/user/team/invitationLink/list`);
export const postAcceptInvitationLink = (linkId: string) =>
POST<string>(`/proApi/support/user/team/invitationLink/accept`, { linkId });
export const getInvitationInfo = (linkId: string) =>
GET<InvitationInfoType>(`/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<CollaboratorItemType[]>(`/proApi/support/user/team/collaborator/list`);