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
26 changed files with 719 additions and 251 deletions

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>