From 276b6639eef2b4095c73af7e1d02d8fb33885666 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AD=99=E6=B0=B8=E5=BC=BA?=
<11704063+s-yongqiang@user.noreply.gitee.com>
Date: Tue, 9 Apr 2024 17:12:55 +0800
Subject: [PATCH] Invitation to join group link API completed
---
.../dialog/group-invite-members-dialog.js | 112 ++++++++++++++
.../src/css/group-invite-members-dialog.css | 40 +++++
frontend/src/pages/groups/group-view.js | 17 +++
seahub/api2/endpoints/group_members.py | 137 +++++++++++++++++-
seahub/profile/models.py | 70 +++++++++
seahub/urls.py | 5 +-
6 files changed, 376 insertions(+), 5 deletions(-)
create mode 100644 frontend/src/components/dialog/group-invite-members-dialog.js
create mode 100644 frontend/src/css/group-invite-members-dialog.css
diff --git a/frontend/src/components/dialog/group-invite-members-dialog.js b/frontend/src/components/dialog/group-invite-members-dialog.js
new file mode 100644
index 00000000000..a913a8ce788
--- /dev/null
+++ b/frontend/src/components/dialog/group-invite-members-dialog.js
@@ -0,0 +1,112 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Modal, ModalHeader, ModalBody } from 'reactstrap';
+import copy from 'copy-to-clipboard';
+import toaster from '../toast';
+import { gettext } from '../../utils/constants';
+import { seafileAPI } from '../../utils/seafile-api';
+import { Utils } from '../../utils/utils';
+
+import '../../css/group-invite-members-dialog.css';
+
+const propTypes = {
+ groupID: PropTypes.string.isRequired,
+ toggleGroupInviteDialog: PropTypes.func.isRequired,
+};
+
+class GroupInviteMembersDialog extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ inviteList: [],
+ };
+ }
+
+ componentDidMount() {
+ this.listInviteLinks();
+ }
+
+ listInviteLinks = () => {
+ seafileAPI.getGroupInviteLinks(this.props.groupID).then((res) => {
+ this.setState({ inviteList: res.data.group_invite_link_list });
+ }).catch(error => {
+ this.onError(error);
+ });
+ };
+
+ addInviteLink = () => {
+ seafileAPI.addGroupInviteLinks(this.props.groupID).then(() => {
+ this.listInviteLinks();
+ }).catch(error => {
+ this.onError(error);
+ });
+ };
+
+ deleteLink = (token) => {
+ seafileAPI.deleteGroupInviteLinks(this.props.groupID, token).then(() => {
+ this.listInviteLinks();
+ }).catch(error => {
+ this.onError(error);
+ });
+ };
+
+ onError = (error) => {
+ let errMsg = Utils.getErrorMsg(error, true);
+ if (!error.response || error.response.status !== 403) {
+ toaster.danger(errMsg);
+ }
+ };
+
+ copyLink = () => {
+ const inviteLinkItem = this.state.inviteList[0];
+ copy(inviteLinkItem.link);
+ const message = gettext('Invitation link has been copied to clipboard');
+ toaster.success((message), {
+ duration: 2
+ });
+ };
+
+ toggle = () => {
+ this.props.toggleGroupInviteDialog();
+ };
+
+ render() {
+ const { inviteList } = this.state;
+ const link = inviteList[0];
+ return (
+
+ {gettext('Invite members')}
+
+ {link ?
+ <>
+
+ {gettext('Group invitation link')}
+
+
+
{link.link}
+
+
+
+
+
+ >
+ :
+ <>
+
+ {gettext('No group invitation link yet. Group invitation link let registered users to join the group by clicking a link.')}
+
+
+ >
+ }
+
+
+ );
+ }
+}
+
+GroupInviteMembersDialog.propTypes = propTypes;
+
+export default GroupInviteMembersDialog;
diff --git a/frontend/src/css/group-invite-members-dialog.css b/frontend/src/css/group-invite-members-dialog.css
new file mode 100644
index 00000000000..bc6dfc395b0
--- /dev/null
+++ b/frontend/src/css/group-invite-members-dialog.css
@@ -0,0 +1,40 @@
+.group-invite-members th,
+.group-invite-members td {
+ vertical-align: middle;
+ text-align: left;
+}
+
+.group-invite-members .no-link-tip {
+ line-height: 24px;
+ color: #999;
+}
+
+.invite-link-item {
+ display: flex;
+ margin: 1rem 0 2.5rem;
+}
+
+.invite-link-item .form-item {
+ width: calc(100% - 120px);
+ padding-left: 10px;
+ height: 40px;
+ line-height: 40px;
+ border: 1px solid #ccc;
+ border-right: none;
+}
+
+.invite-link-item .invite-link-copy {
+ width: 72px;
+}
+
+.invite-link-item .invite-link-copy-btn {
+ width: 72px;
+ height: 40px;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.invite-link-item .delete-link-btn {
+ color: #999;
+ width: 40px;
+}
diff --git a/frontend/src/pages/groups/group-view.js b/frontend/src/pages/groups/group-view.js
index 35c97a718b9..a63a058c2f9 100644
--- a/frontend/src/pages/groups/group-view.js
+++ b/frontend/src/pages/groups/group-view.js
@@ -21,6 +21,7 @@ import ManageMembersDialog from '../../components/dialog/manage-members-dialog';
import LeaveGroupDialog from '../../components/dialog/leave-group-dialog';
import SharedRepoListView from '../../components/shared-repo-list-view/shared-repo-list-view';
import SortOptionsDialog from '../../components/dialog/sort-options';
+import GroupInviteMembersDialog from '../../components/dialog/group-invite-members-dialog';
import '../../css/group-view.css';
@@ -61,6 +62,7 @@ class GroupView extends React.Component {
showTransferGroupDialog: false,
showImportMembersDialog: false,
showManageMembersDialog: false,
+ showInviteMembersDialog: false,
groupMembers: [],
isLeaveGroupDialogOpen: false,
};
@@ -71,6 +73,7 @@ class GroupView extends React.Component {
this.loadGroup(groupID);
}
+
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.groupID !== this.props.groupID) {
this.loadGroup(nextProps.groupID);
@@ -310,6 +313,13 @@ class GroupView extends React.Component {
});
};
+ toggleInviteMembersDialog = () => {
+ this.setState({
+ showInviteMembersDialog: !this.state.showInviteMembersDialog,
+ showGroupDropdown: false,
+ });
+ };
+
toggleLeaveGroupDialog = () => {
this.setState({
isLeaveGroupDialogOpen: !this.state.isLeaveGroupDialogOpen,
@@ -472,6 +482,7 @@ class GroupView extends React.Component {
}
{
@@ -618,6 +629,12 @@ class GroupView extends React.Component {
onGroupChanged={this.props.onGroupChanged}
/>
}
+ {this.state.showInviteMembersDialog &&
+
+ }
);
}
diff --git a/seahub/api2/endpoints/group_members.py b/seahub/api2/endpoints/group_members.py
index 904cb1b4e91..b6810e6c38f 100644
--- a/seahub/api2/endpoints/group_members.py
+++ b/seahub/api2/endpoints/group_members.py
@@ -3,7 +3,7 @@
from io import BytesIO
from openpyxl import load_workbook
-from django.http import HttpResponse
+from django.http import HttpResponse, HttpResponseRedirect
from django.utils.translation import gettext as _
from rest_framework.authentication import SessionAuthentication
@@ -21,17 +21,18 @@
from seahub.api2.authentication import TokenAuthentication
from seahub.avatar.settings import AVATAR_DEFAULT_SIZE
from seahub.base.templatetags.seahub_tags import email2nickname
-from seahub.utils import string2list, is_org_context, get_file_type_and_ext
+from seahub.utils import string2list, is_org_context, get_file_type_and_ext, render_error
from seahub.utils.ms_excel import write_xls
from seahub.utils.error_msg import file_type_error_msg
from seahub.base.accounts import User
from seahub.group.signals import add_user_to_group
from seahub.group.utils import is_group_member, is_group_admin, \
is_group_owner, is_group_admin_or_owner, get_group_member_info
-from seahub.profile.models import Profile
-
+from seahub.profile.models import Profile, GroupInviteLinkModel
from .utils import api_check_group
+from seahub.settings import SERVICE_URL, MULTI_TENANCY
+from seahub.auth.decorators import login_required
logger = logging.getLogger(__name__)
@@ -552,3 +553,131 @@ def get(self, request):
wb.save(response)
return response
+
+
+def is_group_owner_or_admin(group, email):
+ if email == group.creator_name:
+ return True
+ return ccnet_api.check_group_staff(group.id, email)
+
+
+class GroupInviteLinks(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ @api_check_group
+ def get(self, request, group_id):
+ """
+ Get invitation link
+ """
+ group_id = int(group_id)
+ email = request.user.username
+
+ group = ccnet_api.get_group(group_id)
+ if MULTI_TENANCY:
+ error_msg = ' Multiple tenancy is not supported.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not group:
+ error_msg = 'group not found.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ if not is_group_owner_or_admin(group, email):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ try:
+ invite_link_query_set = GroupInviteLinkModel.objects.filter(group_id=group_id)
+ except Exception as e:
+ logger.error(f'query group invite links failed. {e}')
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
+
+ return Response({'group_invite_link_list': [group_invite_link.to_dict() for group_invite_link in
+ invite_link_query_set]})
+
+ @api_check_group
+ def post(self, request, group_id):
+ group_id = int(group_id)
+ email = request.user.username
+
+ group = ccnet_api.get_group(group_id)
+ if MULTI_TENANCY:
+ error_msg = ' Multiple tenancy is not supported.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not group:
+ error_msg = 'group not found.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ if not is_group_owner_or_admin(group, email):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ try:
+ invite_link = GroupInviteLinkModel.objects.create_link(group_id, email)
+ except Exception as e:
+ logger.error(f'create group invite links failed. {e}')
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
+
+ return Response(invite_link.to_dict())
+
+
+class GroupInviteLink(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ @api_check_group
+ def delete(self, request, group_id, token):
+ group_id = int(group_id)
+ email = request.user.username
+
+ group = ccnet_api.get_group(group_id)
+ if MULTI_TENANCY:
+ error_msg = ' Multiple tenancy is not supported.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not group:
+ error_msg = 'group not found.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ if not is_group_owner_or_admin(group, email):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ try:
+ GroupInviteLinkModel.objects.filter(token=token, group_id=group_id).delete()
+ except Exception as e:
+ logger.error(f'delete group invite links failed. {e}')
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
+
+ return Response({'success': True})
+
+
+@login_required
+def group_invite(request, token):
+ """
+ registered user add to group
+ """
+ email = request.user.username
+ next_url = request.GET.get('next', '/')
+ redirect_to = SERVICE_URL.rstrip('/') + '/' + next_url.lstrip('/')
+ group_invite_link = GroupInviteLinkModel.objects.filter(token=token).first()
+ if not group_invite_link:
+ return render_error(request, _('Group invite link does not exist'))
+
+ if is_group_member(group_invite_link.group_id, email):
+
+ return HttpResponseRedirect(redirect_to)
+
+ if not group_invite_link.created_by:
+ return render_error(request, _('Group invite link broken'))
+
+ try:
+ ccnet_api.group_add_member(group_invite_link.group_id, group_invite_link.created_by, email)
+ except Exception as e:
+ logger.error(f'group invite add user failed. {e}')
+ return render_error(request, 'Internal Server Error')
+
+ return HttpResponseRedirect(redirect_to)
diff --git a/seahub/profile/models.py b/seahub/profile/models.py
index 1fff6711b72..914577ffc15 100644
--- a/seahub/profile/models.py
+++ b/seahub/profile/models.py
@@ -14,6 +14,9 @@
from seahub.signals import institution_deleted
from seahub.institutions.models import InstitutionAdmin
+import uuid
+from seahub.settings import SERVICE_URL
+from seahub.utils.timeutils import datetime_to_isoformat_timestr
# Get an instance of a logger
logger = logging.getLogger(__name__)
@@ -21,6 +24,40 @@ class DuplicatedContactEmailError(Exception):
pass
+class GroupInviteLinkModelManager(models.Manager):
+ def create_link(self, group_id, email):
+ token = uuid.uuid4().hex[:8]
+ while self.model.objects.filter(token=token).exists():
+ token = uuid.uuid4().hex[:8]
+
+ group_invite_link = super(GroupInviteLinkModelManager, self).create(
+ group_id=group_id, token=token, created_by=email)
+ return group_invite_link
+
+
+class GroupInviteLinkModel(models.Model):
+ token = models.CharField(max_length=40, db_index=True)
+ group_id = models.IntegerField(db_index=True, null=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+ created_by = models.CharField(max_length=255)
+
+ objects = GroupInviteLinkModelManager()
+
+ class Meta:
+ db_table = 'group_invite_link'
+
+ def to_dict(self):
+ result = {
+ 'id': self.pk,
+ 'token': self.token,
+ 'group_id': self.group_id,
+ 'created_at': datetime_to_isoformat_timestr(self.created_at),
+ 'created_by': email2nickname(self.created_by),
+ 'link': f"{SERVICE_URL.rstrip('/')}/group-invite/{self.token}/",
+ }
+ return result
+
+
class ProfileManager(models.Manager):
def add_or_update(self, username, nickname=None, intro=None, lang_code=None,
login_id=None, contact_email=None, institution=None, list_in_address_book=None):
@@ -241,3 +278,36 @@ def remove_user_for_inst_deleted(sender, **kwargs):
Profile.objects.filter(institution=inst_name).update(institution="")
InstitutionAdmin.objects.filter(institution__name=inst_name).delete()
+
+
+from seahub.profile.settings import NICKNAME_CACHE_PREFIX
+from seahub.utils import normalize_cache_key
+from django import template
+
+register = template.Library()
+
+
+@register.filter(name='email2nickname')
+def email2nickname(value):
+ """
+ Return nickname if it exists and it's not an empty string,
+ otherwise return short email.
+ """
+ if not value:
+ return ''
+
+ key = normalize_cache_key(value, NICKNAME_CACHE_PREFIX)
+ cached_nickname = cache.get(key)
+ if cached_nickname and cached_nickname.strip():
+ return cached_nickname.strip()
+
+ profile = get_first_object_or_none(Profile.objects.filter(user=value))
+ if profile is not None and profile.nickname and profile.nickname.strip():
+ nickname = profile.nickname.strip()
+ else:
+ contact_email = email2contact_email(value)
+ nickname = contact_email.split('@')[0]
+
+ cache.set(key, nickname, NICKNAME_CACHE_TIMEOUT)
+ return nickname
+
diff --git a/seahub/urls.py b/seahub/urls.py
index 338e0dccfb4..0968e1e22bb 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -38,7 +38,7 @@
from seahub.api2.endpoints.address_book.members import AddressBookGroupsSearchMember
from seahub.api2.endpoints.group_members import GroupMembers, GroupSearchMember, GroupMember, \
- GroupMembersBulk, GroupMembersImport, GroupMembersImportExample
+ GroupMembersBulk, GroupMembersImport, GroupMembersImportExample, GroupInviteLinks, GroupInviteLink, group_invite
from seahub.api2.endpoints.search_group import SearchGroup
from seahub.api2.endpoints.share_links import ShareLinks, ShareLink, \
ShareLinkOnlineOfficeLock, ShareLinkDirents, ShareLinkSaveFileToRepo, \
@@ -344,6 +344,9 @@
re_path(r'^api/v2.1/groups/(?P\d+)/members/(?P[^/]+)/$', GroupMember.as_view(), name='api-v2.1-group-member'),
re_path(r'^api/v2.1/search-group/$', SearchGroup.as_view(), name='api-v2.1-search-group'),
+ re_path(r'^api/v2.1/groups/(?P\d+)/invite-links/$', GroupInviteLinks.as_view(),name='api-v2.1-group-invite-links'),
+ re_path(r'^api/v2.1/groups/(?P\d+)/invite-links/(?P[-0-9a-f]{8})/$', GroupInviteLink.as_view(), name='api-v2.1-group-invite-link'),
+ re_path(r'^group-invite/(?P[-0-9a-f]{8})/$', group_invite, name='group_invite'),
## address book
re_path(r'^api/v2.1/address-book/groups/(?P\d+)/sub-groups/$', AddressBookGroupsSubGroups.as_view(), name='api-v2.1-address-book-groups-sub-groups'),
re_path(r'^api/v2.1/address-book/groups/(?P\d+)/search-member/$', AddressBookGroupsSearchMember.as_view(), name='api-v2.1-address-book-search-member'),