From 7bde3251a61581b6442e07e194c04ad51dbfeccc Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 29 May 2023 18:54:38 +0200 Subject: [PATCH 01/14] initial commit --- mwdb/app.py | 8 +- mwdb/core/auth.py | 1 + mwdb/model/user.py | 18 ++++ mwdb/resources/group.py | 146 ++++++++++++++++++++++++++++- mwdb/templates/mail/invitation.txt | 5 + mwdb/web/src/commons/api/index.tsx | 4 + 6 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 mwdb/templates/mail/invitation.txt diff --git a/mwdb/app.py b/mwdb/app.py index ecc5e51e7..a671f44c2 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -45,7 +45,12 @@ FileItemResource, FileResource, ) -from mwdb.resources.group import GroupListResource, GroupMemberResource, GroupResource +from mwdb.resources.group import ( + GroupListResource, + GroupMemberResource, + GroupResource, + RequestGroupInviteLinkResource, +) from mwdb.resources.karton import KartonAnalysisResource, KartonObjectResource from mwdb.resources.metakey import ( MetakeyDefinitionManageResource, @@ -340,6 +345,7 @@ def require_auth(): api.add_resource(GroupListResource, "/group") api.add_resource(GroupResource, "/group/") api.add_resource(GroupMemberResource, "/group//member/") +api.add_resource(RequestGroupInviteLinkResource, "/group//invite/") # OAuth endpoints if app_config.mwdb.enable_oidc: diff --git a/mwdb/core/auth.py b/mwdb/core/auth.py index ab3f89ac2..16485feba 100644 --- a/mwdb/core/auth.py +++ b/mwdb/core/auth.py @@ -12,6 +12,7 @@ class AuthScope(Enum): api_key = "api_key" set_password = "set_password" download_file = "download_file" + group_invite = "group_invite" def generate_token(fields, scope, expiration=None): diff --git a/mwdb/model/user.py b/mwdb/model/user.py index dcb6c4993..4033db424 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -200,6 +200,24 @@ def generate_set_password_token(self): expiration=14 * 24 * 3600, ) + def generate_group_invite_token(self, group_id, inviter): + return self._generate_token( + user_fields=[], + scope=AuthScope.group_invite, + expiration=7 * 24 * 3600, # valid for 1 week + group_id=group_id, + inviter=inviter, + ) + + @staticmethod + def verify_group_invite_token(token): + result = User._verify_token( + token=token, + fields=[], + scope=AuthScope.group_invite, + ) + return None if result is None else result[0] + @staticmethod def verify_session_token(token) -> Optional[Tuple["User", Optional[str]]]: return User._verify_token( diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 7594b20c3..d869417ce 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -2,9 +2,11 @@ from flask_restful import Resource from sqlalchemy import exists from sqlalchemy.orm import joinedload -from werkzeug.exceptions import Conflict, Forbidden, NotFound +from werkzeug.exceptions import Conflict, Forbidden, InternalServerError, NotFound from mwdb.core.capabilities import Capabilities +from mwdb.core.config import app_config +from mwdb.core.mail import MailError, send_email_notification from mwdb.core.plugins import hooks from mwdb.core.rate_limit import rate_limited_resource from mwdb.model import Group, Member, User, db @@ -583,3 +585,145 @@ def delete(self, name, login): ) schema = GroupSuccessResponseSchema() return schema.dump({"name": name}) + + +@rate_limited_resource +class RequestGroupInviteLinkResource(Resource): + @requires_authorization + def post(self, name, invited_user): + """ + --- + summary: Request invitation link + description: | + Creates invitation link and sends an email to the invited user. + + Invitation link works only for secified group and specified user + + Requires `manage_users` capability or group_admin membership. + security: + - bearerAuth: [] + tags: + - group + parameters: + - in: path + name: name + schema: + type: string + description: Group name + - in: path + name: invited_user + schema: + type: string + description: Invited user login + responses: + 200: + description: When link was created successfully + 400: + description: When request body is invalid + 403: + description: | + When user doesn't have enough permissions, + group is immutable or invited user is pending + 404: + description: When invited user or group doesn't exist + 409: + description: When user is already a member of this group + 503: + description: | + Request canceled due to database statement timeout. + """ + group_obj = (db.session.query(Group).filter(Group.name == name)).first() + + if group_obj is None: + raise NotFound("Group does not exist or you are not it's member") + + member_obj = ( + db.session.query(Member) + .filter(Member.group_id == group_obj.id) + .filter(Member.user_id == g.auth_user.id) + ).first() + + if member_obj is None: + raise NotFound("Group does not exist or you are not it's member") + + if not member_obj.group_admin: + raise Forbidden("You do not have group_admin role") + + if group_obj.private or group_obj.immutable: # should it be more strict? + raise Forbidden("You cannot invite users to this group") + + invited_user_obj = ( + db.session.query(User).filter(User.login == invited_user) + ).first() + + if invited_user_obj is None: + raise NotFound("Inveted user does not exist") + + if invited_user_obj.pending: + raise Forbidden("Invited user is pending") + + test_obj = ( + db.session.query(Member) + .filter(Member.group_id == group_obj.id) + .filter(Member.user_id == invited_user_obj.id) + ).first() + if test_obj is not None: + raise Conflict("Invited user is already a member of this group") + + token = invited_user_obj.generate_group_invite_token( + group_obj.id, g.auth_user.login + ) + + try: + send_email_notification( + "invitation", + "MWDB: You have been invited to a new group", + invited_user_obj.email, + base_url=app_config.mwdb.base_url, + login=invited_user_obj.login, + group_invite_token=token, + ) + except MailError: + logger.exception("Can't send e-mail notification") + raise InternalServerError( + "SMTP server needed to fulfill this request is" + " not configured or unavailable." + ) + + +@rate_limited_resource +class JoinGroupInviteLinkResource(Resource): + @requires_authorization + def post(selt): + """ + --- + summary: Join group using invitation link + description: | + Join group using link + + security: + - bearerAuth: [] + tags: + - group + responses: + 200: + description: When user joined group successfully + 400: + description: When request body is invalid + 403: + description: When there was a problem with the token + 409: + description: When user is already a member of this group + 503: + description: | + Request canceled due to database statement timeout. + """ + token = request.args.get("token") + + if token is None: + raise Forbidden("Token not found") + + invite_data = User.verify_group_invite_token(token) + + if invite_data is None: + raise Forbidden("There was a problem while decoding your token") diff --git a/mwdb/templates/mail/invitation.txt b/mwdb/templates/mail/invitation.txt new file mode 100644 index 000000000..7af77ceda --- /dev/null +++ b/mwdb/templates/mail/invitation.txt @@ -0,0 +1,5 @@ +Hi {login} + +You have been invited to join new group. + +To view the invitation click this link: {base_url}/.../?token={group_invite_token} \ No newline at end of file diff --git a/mwdb/web/src/commons/api/index.tsx b/mwdb/web/src/commons/api/index.tsx index 16a7f9711..3b3971483 100644 --- a/mwdb/web/src/commons/api/index.tsx +++ b/mwdb/web/src/commons/api/index.tsx @@ -412,6 +412,10 @@ function setGroupAdmin( return axios.put(`/group/${name}/member/${member}`, { group_admin }); } +function requestGroupInviteLink(name: string, invited_user: string){ + return axios.post(`/group/${name}/invite/${invited_user}`) +} + function getUsers(): GetUsersResponse { return axios.get("/user", { timeout: undefined }); } From 16cb3b12a4090ea366799ed769e86acb998c7728 Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 30 May 2023 11:08:40 +0200 Subject: [PATCH 02/14] probably working backend --- mwdb/app.py | 2 ++ mwdb/model/user.py | 21 +++++++++++++-------- mwdb/resources/group.py | 20 +++++++++++++++----- mwdb/templates/mail/invitation.txt | 2 +- mwdb/web/src/commons/api/index.tsx | 4 ++-- 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/mwdb/app.py b/mwdb/app.py index a671f44c2..badec186a 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -49,6 +49,7 @@ GroupListResource, GroupMemberResource, GroupResource, + JoinGroupInviteLinkResource, RequestGroupInviteLinkResource, ) from mwdb.resources.karton import KartonAnalysisResource, KartonObjectResource @@ -346,6 +347,7 @@ def require_auth(): api.add_resource(GroupResource, "/group/") api.add_resource(GroupMemberResource, "/group//member/") api.add_resource(RequestGroupInviteLinkResource, "/group//invite/") +api.add_resource(JoinGroupInviteLinkResource, "/group/join") # OAuth endpoints if app_config.mwdb.enable_oidc: diff --git a/mwdb/model/user.py b/mwdb/model/user.py index 4033db424..cd64179df 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -209,14 +209,19 @@ def generate_group_invite_token(self, group_id, inviter): inviter=inviter, ) - @staticmethod - def verify_group_invite_token(token): - result = User._verify_token( - token=token, - fields=[], - scope=AuthScope.group_invite, - ) - return None if result is None else result[0] + def join_group_with_token(self, token): + data = verify_token(token, AuthScope.group_invite) + + if data is None: + return False + + group_id = data.get("group_id") + if group_id is None: + return False + + group_obj = db.session.query(Group).filter(Group.id == group_id).first() + + return group_obj.add_member(self) @staticmethod def verify_session_token(token) -> Optional[Tuple["User", Optional[str]]]: diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index d869417ce..1e8befcd2 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -690,6 +690,8 @@ def post(self, name, invited_user): " not configured or unavailable." ) + return token + @rate_limited_resource class JoinGroupInviteLinkResource(Resource): @@ -703,17 +705,24 @@ def post(selt): security: - bearerAuth: [] + parameters: + - in: query + name: token + schema: + type: string + description: token tags: - group responses: 200: description: When user joined group successfully + content: + application/json: + schema: GroupSuccessResponseSchema 400: description: When request body is invalid 403: description: When there was a problem with the token - 409: - description: When user is already a member of this group 503: description: | Request canceled due to database statement timeout. @@ -723,7 +732,8 @@ def post(selt): if token is None: raise Forbidden("Token not found") - invite_data = User.verify_group_invite_token(token) + success = g.auth_user.join_group_with_token(token) - if invite_data is None: - raise Forbidden("There was a problem while decoding your token") + if not success: + raise Forbidden("There was a problem while processing your request") + db.session.commit() diff --git a/mwdb/templates/mail/invitation.txt b/mwdb/templates/mail/invitation.txt index 7af77ceda..1d1a55de5 100644 --- a/mwdb/templates/mail/invitation.txt +++ b/mwdb/templates/mail/invitation.txt @@ -2,4 +2,4 @@ Hi {login} You have been invited to join new group. -To view the invitation click this link: {base_url}/.../?token={group_invite_token} \ No newline at end of file +To view the invitation click this link: {base_url}/group/join/?token={group_invite_token} \ No newline at end of file diff --git a/mwdb/web/src/commons/api/index.tsx b/mwdb/web/src/commons/api/index.tsx index 3b3971483..dc60ca41e 100644 --- a/mwdb/web/src/commons/api/index.tsx +++ b/mwdb/web/src/commons/api/index.tsx @@ -412,8 +412,8 @@ function setGroupAdmin( return axios.put(`/group/${name}/member/${member}`, { group_admin }); } -function requestGroupInviteLink(name: string, invited_user: string){ - return axios.post(`/group/${name}/invite/${invited_user}`) +function requestGroupInviteLink(name: string, invited_user: string) { + return axios.post(`/group/${name}/invite/${invited_user}`); } function getUsers(): GetUsersResponse { From 3c7a848150284e6c57ab262dda6e97bdd783fba0 Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 30 May 2023 17:08:00 +0200 Subject: [PATCH 03/14] improve security, initial front-end --- mwdb/model/user.py | 34 +++++----- mwdb/resources/group.py | 97 +++++++++++++++++++++++++-- mwdb/templates/mail/invitation.txt | 2 +- mwdb/web/src/App.jsx | 2 + mwdb/web/src/commons/api/index.tsx | 11 +++ mwdb/web/src/components/GroupJoin.jsx | 57 ++++++++++++++++ 6 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 mwdb/web/src/components/GroupJoin.jsx diff --git a/mwdb/model/user.py b/mwdb/model/user.py index cd64179df..6247029b8 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -167,7 +167,7 @@ def _generate_token(self, user_fields, scope, expiration, **extra_fields): return token @staticmethod - def _verify_token(token, fields, scope) -> Optional[Tuple["User", Optional[str]]]: + def _verify_token(token, fields, scope) -> Optional[Tuple["User", dict]]: data = verify_token(token, scope) if data is None: return None @@ -183,7 +183,7 @@ def _verify_token(token, fields, scope) -> Optional[Tuple["User", Optional[str]] if data[field] != getattr(user_obj, field): return None - return user_obj, data.get("provider") + return user_obj, data def generate_session_token(self, provider=None): return self._generate_token( @@ -202,37 +202,33 @@ def generate_set_password_token(self): def generate_group_invite_token(self, group_id, inviter): return self._generate_token( - user_fields=[], + user_fields=["identity_ver"], scope=AuthScope.group_invite, expiration=7 * 24 * 3600, # valid for 1 week group_id=group_id, inviter=inviter, ) - def join_group_with_token(self, token): - data = verify_token(token, AuthScope.group_invite) - - if data is None: - return False - - group_id = data.get("group_id") - if group_id is None: - return False - - group_obj = db.session.query(Group).filter(Group.id == group_id).first() - - return group_obj.add_member(self) + @staticmethod + def verify_group_invite_token(token: str) -> Optional[dict]: + result = User._verify_token( + token, + ["identity_ver"], + scope=AuthScope.group_invite, + ) + return None if result is None else result[1] @staticmethod - def verify_session_token(token) -> Optional[Tuple["User", Optional[str]]]: - return User._verify_token( + def verify_session_token(token: str) -> Optional[Tuple["User", Optional[str]]]: + result = User._verify_token( token, ["password_ver", "identity_ver"], scope=AuthScope.session, ) + return None if result is None else result[0], result[1].get("provider") @staticmethod - def verify_set_password_token(token) -> Optional["User"]: + def verify_set_password_token(token: str) -> Optional["User"]: result = User._verify_token( token, ["password_ver"], diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 1e8befcd2..c735fcce5 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -2,7 +2,13 @@ from flask_restful import Resource from sqlalchemy import exists from sqlalchemy.orm import joinedload -from werkzeug.exceptions import Conflict, Forbidden, InternalServerError, NotFound +from werkzeug.exceptions import ( + BadRequest, + Conflict, + Forbidden, + InternalServerError, + NotFound, +) from mwdb.core.capabilities import Capabilities from mwdb.core.config import app_config @@ -730,10 +736,91 @@ def post(selt): token = request.args.get("token") if token is None: - raise Forbidden("Token not found") + raise BadRequest("Token not found") + + token_data = User.verify_group_invite_token(token) + if not token_data: + raise BadRequest("There was a problem while processing your token") - success = g.auth_user.join_group_with_token(token) + invited_user_login = token_data.get("login") + if not g.auth_user.login == invited_user_login: + raise Forbidden("This invitation is not for you") - if not success: - raise Forbidden("There was a problem while processing your request") + group_id = token_data.get("group_id") + if group_id is None: + raise BadRequest("Invalid token") + + member = ( + db.session.query(Member) + .filter(Member.group_id == group_id) + .filter(Member.user_id == g.auth_user.id) + ).first() + + if member is not None: + raise Conflict("You are already member of this group") + + group_obj = db.session.query(Group).filter(Group.id == group_id).first() + if group_obj is None: + raise NotFound("This group does not exist") + + group_obj.add_member(g.auth_user) db.session.commit() + + schema = GroupSuccessResponseSchema() + return schema.dump({"name": group_obj.name}) + + @requires_authorization + def put(self): + """ + --- + summary: Get information about group from invitation token + description: | + Get information about group from invitation token + + security: + - bearerAuth: [] + parameters: + - in: query + name: token + schema: + type: string + description: token + tags: + - group + responses: + 200: + description: When data was read successfully + content: + application/json: + schema: GroupSuccessResponseSchema + 400: + description: When request body is invalid + 403: + description: When there was a problem with the token + 503: + description: | + Request canceled due to database statement timeout. + """ + token = request.args.get("token") + + if token is None: + raise BadRequest("Token not found") + + token_data = User.verify_group_invite_token(token) + if not token_data: + raise BadRequest("There was a problem while processing your token") + + invited_user_login = token_data.get("login") + if not g.auth_user.login == invited_user_login: + raise Forbidden("This invitation is not for you") + + group_id = token_data.get("group_id") + if group_id is None: + raise BadRequest("Invalid token") + + group_obj = db.session.query(Group).filter(Group.id == group_id).first() + if group_obj is None: + raise NotFound("This group does not exist") + + schema = GroupSuccessResponseSchema() + return schema.dump({"name": group_obj.name}) diff --git a/mwdb/templates/mail/invitation.txt b/mwdb/templates/mail/invitation.txt index 1d1a55de5..903d3c985 100644 --- a/mwdb/templates/mail/invitation.txt +++ b/mwdb/templates/mail/invitation.txt @@ -2,4 +2,4 @@ Hi {login} You have been invited to join new group. -To view the invitation click this link: {base_url}/group/join/?token={group_invite_token} \ No newline at end of file +To view the invitation click this link: {base_url}/group/invite?token={group_invite_token} \ No newline at end of file diff --git a/mwdb/web/src/App.jsx b/mwdb/web/src/App.jsx index 075a0609a..144ff8513 100644 --- a/mwdb/web/src/App.jsx +++ b/mwdb/web/src/App.jsx @@ -12,6 +12,7 @@ import Navigation from "./components/Navigation"; import RecentConfigs from "./components/RecentConfigs"; import RecentSamples from "./components/RecentSamples"; import ConfigStats from "./components/ConfigStats"; +import GroupJoin from "./components/GroupJoin"; import RecentBlobs from "./components/RecentBlobs"; import ShowSample from "./components/ShowSample"; import ShowConfig from "./components/ShowConfig"; @@ -104,6 +105,7 @@ function AppRoutes() { } /> } /> }> + } /> } /> } /> } /> diff --git a/mwdb/web/src/commons/api/index.tsx b/mwdb/web/src/commons/api/index.tsx index dc60ca41e..ed1887e0f 100644 --- a/mwdb/web/src/commons/api/index.tsx +++ b/mwdb/web/src/commons/api/index.tsx @@ -416,6 +416,14 @@ function requestGroupInviteLink(name: string, invited_user: string) { return axios.post(`/group/${name}/invite/${invited_user}`); } +function getInvitationData(token: string) { + return axios.put(`/group/join?token=${token}`); +} + +function acceptGroupInvitation(token: string) { + return axios.post(`/group/join?token=${token}`); +} + function getUsers(): GetUsersResponse { return axios.get("/user", { timeout: undefined }); } @@ -825,6 +833,9 @@ export const api = { getPendingUsers, acceptPendingUser, rejectPendingUser, + requestGroupInviteLink, + getInvitationData, + acceptGroupInvitation, getUsers, getUser, getUserProfile, diff --git a/mwdb/web/src/components/GroupJoin.jsx b/mwdb/web/src/components/GroupJoin.jsx new file mode 100644 index 000000000..b35c520be --- /dev/null +++ b/mwdb/web/src/components/GroupJoin.jsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { ConfirmationModal, getErrorMessage } from "@mwdb-web/commons/ui"; +import { api } from "@mwdb-web/commons/api"; +import { toast } from "react-toastify"; +import { useNavigate } from "react-router-dom"; + +export default function GroupJoin() { + const token = new URLSearchParams(window.location.search).get("token"); + const [groupName, setGroupName] = useState(""); + const navigate = useNavigate(); + + async function getInvitationDetails() { + try { + let response = await api.getInvitationData(token); + setGroupName(response.data.name); + } catch (error) { + toast(getErrorMessage(error), { + type: "error", + }); + navigate("/"); + } + } + async function acceptInvitation() { + try { + await api.acceptGroupInvitation(token); + toast("You are now a member", { + type: "success", + }); + navigate("/"); + } catch (error) { + console.log(error); + toast(getErrorMessage(error), { + type: "error", + }); + } + } + + useEffect(() => { + getInvitationDetails(); + }, []); + + return ( +
+
+ { + navigate("/"); + }} + onConfirm={acceptInvitation} + > +
+ ); +} From cef5389a72e0398acb7ea7aae0cb66c5e1990759 Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 30 May 2023 18:00:33 +0200 Subject: [PATCH 04/14] group invite expiration time is configurable --- mwdb/core/config.py | 4 ++++ mwdb/model/user.py | 5 ++--- mwdb/resources/group.py | 15 ++++++++------- mwdb/schema/group.py | 4 ++++ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/mwdb/core/config.py b/mwdb/core/config.py index 1b54707ad..21f6746a1 100644 --- a/mwdb/core/config.py +++ b/mwdb/core/config.py @@ -134,6 +134,10 @@ class MWDBConfig(Config): enable_sql_profiler = key(cast=intbool, required=False, default=False) log_only_slow_sql = key(cast=intbool, required=False, default=False) + group_invite_expiration_time = key( + cast=int, required=False, default=7 * 24 * 3600 + ) # one week + @section("karton") class KartonConfig(Config): diff --git a/mwdb/model/user.py b/mwdb/model/user.py index 6247029b8..5ff877277 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -200,13 +200,12 @@ def generate_set_password_token(self): expiration=14 * 24 * 3600, ) - def generate_group_invite_token(self, group_id, inviter): + def generate_group_invite_token(self, group_id, expiration): return self._generate_token( user_fields=["identity_ver"], scope=AuthScope.group_invite, - expiration=7 * 24 * 3600, # valid for 1 week + expiration=expiration, group_id=group_id, - inviter=inviter, ) @staticmethod diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index c735fcce5..402c86d3e 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -18,6 +18,7 @@ from mwdb.model import Group, Member, User, db from mwdb.schema.group import ( GroupCreateRequestSchema, + GroupInvitationLinkResponseSchema, GroupItemResponseSchema, GroupListResponseSchema, GroupMemberUpdateRequestSchema, @@ -655,7 +656,7 @@ def post(self, name, invited_user): if not member_obj.group_admin: raise Forbidden("You do not have group_admin role") - if group_obj.private or group_obj.immutable: # should it be more strict? + if group_obj.private or group_obj.immutable: raise Forbidden("You cannot invite users to this group") invited_user_obj = ( @@ -663,21 +664,20 @@ def post(self, name, invited_user): ).first() if invited_user_obj is None: - raise NotFound("Inveted user does not exist") + raise NotFound("Invited user does not exist") if invited_user_obj.pending: raise Forbidden("Invited user is pending") - test_obj = ( + if ( db.session.query(Member) .filter(Member.group_id == group_obj.id) .filter(Member.user_id == invited_user_obj.id) - ).first() - if test_obj is not None: + ).first() is not None: raise Conflict("Invited user is already a member of this group") token = invited_user_obj.generate_group_invite_token( - group_obj.id, g.auth_user.login + group_obj.id, app_config.mwdb.group_invite_expiration_time ) try: @@ -696,7 +696,8 @@ def post(self, name, invited_user): " not configured or unavailable." ) - return token + schema = GroupInvitationLinkResponseSchema() + return schema.dump({"token": token}) @rate_limited_resource diff --git a/mwdb/schema/group.py b/mwdb/schema/group.py index 5c11752fa..4adcb38e2 100644 --- a/mwdb/schema/group.py +++ b/mwdb/schema/group.py @@ -67,3 +67,7 @@ class GroupListResponseSchema(Schema): class GroupSuccessResponseSchema(GroupNameSchemaBase): pass + + +class GroupInvitationLinkResponseSchema(Schema): + token = fields.Str(required=True, allow_none=False) From 6f1142e9fbac2be3f0b31a10fb4a19c92842818f Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 30 May 2023 18:39:32 +0200 Subject: [PATCH 05/14] front-end implementation --- .../Profile/Views/ProfileGroupMembers.jsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.jsx b/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.jsx index 63e9658be..d9b6c45bd 100644 --- a/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.jsx +++ b/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.jsx @@ -8,7 +8,11 @@ import { GroupBadge, useViewAlert, ConfirmationModal, + getErrorMessage, } from "@mwdb-web/commons/ui"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { toast } from "react-toastify"; function ProfileGroupItems({ workspace, updateWorkspace }) { const { setAlert } = useViewAlert(); @@ -73,6 +77,44 @@ function ProfileGroupItems({ workspace, updateWorkspace }) { ); } +function InviteMemberForm({ group_name }) { + const [newMember, setNewMember] = useState(""); + + async function inviteMember(invitedUserLogin) { + try { + await api.requestGroupInviteLink(group_name, invitedUserLogin); + toast("Invitation sent", { type: "success" }); + } catch (error) { + toast(getErrorMessage(error), { type: "error" }); + } + } + + return ( +
+
+ { + setNewMember(event.target.value); + }} + /> + +
+
+ ); +} + export default function ProfileGroupMembers() { const auth = useContext(AuthContext); const { redirectToAlert } = useViewAlert(); @@ -135,6 +177,7 @@ export default function ProfileGroupMembers() { Group {groupName}{" "} members + From 1cc98fc9fb6a8871fa9716c93889aab1c02f9b45 Mon Sep 17 00:00:00 2001 From: Repumba Date: Wed, 31 May 2023 10:49:46 +0200 Subject: [PATCH 06/14] import order --- mwdb/web/src/components/GroupJoin.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mwdb/web/src/components/GroupJoin.jsx b/mwdb/web/src/components/GroupJoin.jsx index b35c520be..2fa74d48c 100644 --- a/mwdb/web/src/components/GroupJoin.jsx +++ b/mwdb/web/src/components/GroupJoin.jsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import { ConfirmationModal, getErrorMessage } from "@mwdb-web/commons/ui"; +import { useNavigate } from "react-router-dom"; import { api } from "@mwdb-web/commons/api"; +import { ConfirmationModal, getErrorMessage } from "@mwdb-web/commons/ui"; import { toast } from "react-toastify"; -import { useNavigate } from "react-router-dom"; export default function GroupJoin() { const token = new URLSearchParams(window.location.search).get("token"); From 3532426a6ab3191c6fb0d82e1d77a6971f2f9deb Mon Sep 17 00:00:00 2001 From: Repumba Date: Wed, 31 May 2023 11:24:54 +0200 Subject: [PATCH 07/14] adapt to TypeScript changes --- mwdb/web/src/components/GroupJoin.jsx | 3 +- .../Profile/Views/ProfileGroupMembers.tsx | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/mwdb/web/src/components/GroupJoin.jsx b/mwdb/web/src/components/GroupJoin.jsx index 2fa74d48c..43b02d5c1 100644 --- a/mwdb/web/src/components/GroupJoin.jsx +++ b/mwdb/web/src/components/GroupJoin.jsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "@mwdb-web/commons/api"; -import { ConfirmationModal, getErrorMessage } from "@mwdb-web/commons/ui"; +import { ConfirmationModal } from "@mwdb-web/commons/ui"; +import { getErrorMessage } from "@mwdb-web/commons/helpers"; import { toast } from "react-toastify"; export default function GroupJoin() { diff --git a/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx b/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx index 4e046ada0..d8aabbb2e 100644 --- a/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx +++ b/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useState } from "react"; import { Navigate, useParams, useOutletContext } from "react-router-dom"; +import { toast } from "react-toastify"; import { isEmpty, isNil } from "lodash"; import { api } from "@mwdb-web/commons/api"; @@ -9,6 +10,47 @@ import { Capability } from "@mwdb-web/types/types"; import { ProfileGroupItems } from "../common/ProfileGroupItems"; import { ProfileOutletContext } from "@mwdb-web/types/context"; import { Group } from "@mwdb-web/types/types"; +import { getErrorMessage } from "@mwdb-web/commons/helpers"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; + +function InviteMemberForm({ groupName }: { groupName: string }) { + const [newMember, setNewMember] = useState(""); + + async function inviteMember(invitedUserLogin: string) { + try { + await api.requestGroupInviteLink(groupName, invitedUserLogin); + toast("Invitation sent", { type: "success" }); + } catch (error) { + toast(getErrorMessage(error), { type: "error" }); + } + } + + return ( +
+
+ { + setNewMember(event.target.value); + }} + /> + +
+
+ ); +} export default function ProfileGroupMembers() { const auth = useContext(AuthContext); @@ -72,6 +114,7 @@ export default function ProfileGroupMembers() { Group {groupName}{" "} members +
From ac18f0af093f1e2185be83958bbfbecfc945b9f5 Mon Sep 17 00:00:00 2001 From: Repumba Date: Wed, 31 May 2023 12:08:47 +0200 Subject: [PATCH 08/14] fix conditions --- mwdb/model/user.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/mwdb/model/user.py b/mwdb/model/user.py index 5ff877277..e14fce822 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -215,7 +215,10 @@ def verify_group_invite_token(token: str) -> Optional[dict]: ["identity_ver"], scope=AuthScope.group_invite, ) - return None if result is None else result[1] + if result is None: + return None + else: + return result[1] @staticmethod def verify_session_token(token: str) -> Optional[Tuple["User", Optional[str]]]: @@ -224,7 +227,10 @@ def verify_session_token(token: str) -> Optional[Tuple["User", Optional[str]]]: ["password_ver", "identity_ver"], scope=AuthScope.session, ) - return None if result is None else result[0], result[1].get("provider") + if result is None: + return None + else: + return result[0], result[1].get("provider") @staticmethod def verify_set_password_token(token: str) -> Optional["User"]: @@ -233,7 +239,10 @@ def verify_set_password_token(token: str) -> Optional["User"]: ["password_ver"], scope=AuthScope.set_password, ) - return None if result is None else result[0] + if result is None: + return None + else: + return result[0] @staticmethod def verify_legacy_token(token): From e9493e1622659e85dcd56ccb71289b31b6f0f0ce Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 5 Jun 2023 12:43:20 +0200 Subject: [PATCH 09/14] add tests --- mwdb/resources/group.py | 7 ++++++- mwdb/schema/group.py | 2 +- tests/backend/test_auth.py | 26 +++++++++++++++++++++++++- tests/backend/utils.py | 16 ++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 402c86d3e..62a964309 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -625,6 +625,9 @@ def post(self, name, invited_user): responses: 200: description: When link was created successfully + content: + application/json: + schema: GroupInvitationLinkResponseSchema 400: description: When request body is invalid 403: @@ -697,7 +700,9 @@ def post(self, name, invited_user): ) schema = GroupInvitationLinkResponseSchema() - return schema.dump({"token": token}) + return schema.dump( + {"link": app_config.mwdb.base_url + "/group/invite?token=" + token} + ) @rate_limited_resource diff --git a/mwdb/schema/group.py b/mwdb/schema/group.py index 4adcb38e2..635909838 100644 --- a/mwdb/schema/group.py +++ b/mwdb/schema/group.py @@ -70,4 +70,4 @@ class GroupSuccessResponseSchema(GroupNameSchemaBase): class GroupInvitationLinkResponseSchema(Schema): - token = fields.Str(required=True, allow_none=False) + link = fields.Str(required=True, allow_none=False) diff --git a/tests/backend/test_auth.py b/tests/backend/test_auth.py index 6375fb42b..0d4fa9b00 100644 --- a/tests/backend/test_auth.py +++ b/tests/backend/test_auth.py @@ -2,7 +2,7 @@ import jwt -from .utils import MwdbTest, ShouldRaise, admin_login, rand_string +from .utils import MwdbTest, ShouldRaise, admin_login, random_name def test_profile_change_invalidate(typical_session, admin_session): @@ -178,3 +178,27 @@ def test_invalid_jwt(admin_session): session.set_auth_token(jwt.encode(missing_sub_jwt, secret_key, algorithm="HS512")) response = session.request("get", "/server") assert not response["is_authenticated"] + + +def test_group_invite(admin_session): + alice_username = random_name() + bob_username = random_name() + group_name = random_name() + admin_session.register_user(alice_username, "AliceAlice") + admin_session.register_user(bob_username, "BobBobBob") + admin_session.create_group(group_name) + admin_session.add_member(group_name, alice_username) + # set Alice as group_admin + admin_session.update_group_admin(group_name, alice_username, True) + + alice_session = MwdbTest() + bob_session = MwdbTest() + alice_session.login_as(alice_username, "AliceAlice") + bob_session.login_as(bob_username, "BobBobBob") + + data = alice_session.request_group_invite_link(group_name, bob_username) + res = bob_session.session.post(data["link"]) + res.raise_for_status() + + members = admin_session.get_group(group_name)["users"] + assert bob_username in members \ No newline at end of file diff --git a/tests/backend/utils.py b/tests/backend/utils.py index 5863af2f3..21018486c 100644 --- a/tests/backend/utils.py +++ b/tests/backend/utils.py @@ -152,6 +152,22 @@ def remove_member(self, name, username): ) res.raise_for_status() + def update_group_admin(self, name: str, username: str, is_admin: bool): + res = self.session.put( + self.mwdb_url + "/group/" + name + "/member/" + username, + json={ + "group_admin": is_admin + } + ) + res.raise_for_status() + + def request_group_invite_link(self, groupname, username): + res = self.session.post( + self.mwdb_url + "/group/" + groupname + "/invite/" + username + ) + res.raise_for_status() + return res.json() + def register_user(self, username, password, capabilities=None): capabilities = capabilities or [] self.login() From 8e9d6e6cac76613f0e1affd91495fe9fac233a21 Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 5 Jun 2023 13:40:51 +0200 Subject: [PATCH 10/14] fix tests --- tests/backend/test_auth.py | 12 ++++++++++-- tests/backend/utils.py | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/backend/test_auth.py b/tests/backend/test_auth.py index 0d4fa9b00..04e250d33 100644 --- a/tests/backend/test_auth.py +++ b/tests/backend/test_auth.py @@ -183,9 +183,11 @@ def test_invalid_jwt(admin_session): def test_group_invite(admin_session): alice_username = random_name() bob_username = random_name() + charlie_username = random_name() group_name = random_name() admin_session.register_user(alice_username, "AliceAlice") admin_session.register_user(bob_username, "BobBobBob") + admin_session.register_user(charlie_username, "CharlieCharlie") admin_session.create_group(group_name) admin_session.add_member(group_name, alice_username) # set Alice as group_admin @@ -193,12 +195,18 @@ def test_group_invite(admin_session): alice_session = MwdbTest() bob_session = MwdbTest() + charlie_session = MwdbTest() alice_session.login_as(alice_username, "AliceAlice") bob_session.login_as(bob_username, "BobBobBob") + charlie_session.login_as(charlie_username, "CharlieCharlie") data = alice_session.request_group_invite_link(group_name, bob_username) - res = bob_session.session.post(data["link"]) - res.raise_for_status() + token = data["link"].split("=")[-1] + + with ShouldRaise(403): + charlie_session.join_group_with_invitation_link(token) + + bob_session.join_group_with_invitation_link(token) members = admin_session.get_group(group_name)["users"] assert bob_username in members \ No newline at end of file diff --git a/tests/backend/utils.py b/tests/backend/utils.py index 21018486c..f49ceee03 100644 --- a/tests/backend/utils.py +++ b/tests/backend/utils.py @@ -167,6 +167,13 @@ def request_group_invite_link(self, groupname, username): ) res.raise_for_status() return res.json() + + def join_group_with_invitation_link(self, token): + res = self.session.post( + self.mwdb_url + "/group/join?token=" + token + ) + res.raise_for_status() + return res.json() def register_user(self, username, password, capabilities=None): capabilities = capabilities or [] From 7434b1bff666872b2175347a60cfbb735b738c68 Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 5 Jun 2023 13:59:02 +0200 Subject: [PATCH 11/14] improve endpoints descriptions --- mwdb/resources/group.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 62a964309..3a8ae7b73 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -735,6 +735,8 @@ def post(selt): description: When request body is invalid 403: description: When there was a problem with the token + 409: + description: When user is already a member of this group 503: description: | Request canceled due to database statement timeout. @@ -814,7 +816,7 @@ def put(self): token_data = User.verify_group_invite_token(token) if not token_data: - raise BadRequest("There was a problem while processing your token") + raise Forbidden("There was a problem while processing your token") invited_user_login = token_data.get("login") if not g.auth_user.login == invited_user_login: @@ -822,7 +824,7 @@ def put(self): group_id = token_data.get("group_id") if group_id is None: - raise BadRequest("Invalid token") + raise Forbidden("Invalid token") group_obj = db.session.query(Group).filter(Group.id == group_id).first() if group_obj is None: From 1062565d606560a02d2a5bda09395afe97b109b3 Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 5 Jun 2023 14:23:42 +0200 Subject: [PATCH 12/14] add new line --- tests/backend/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backend/test_auth.py b/tests/backend/test_auth.py index 04e250d33..303a35e28 100644 --- a/tests/backend/test_auth.py +++ b/tests/backend/test_auth.py @@ -209,4 +209,4 @@ def test_group_invite(admin_session): bob_session.join_group_with_invitation_link(token) members = admin_session.get_group(group_name)["users"] - assert bob_username in members \ No newline at end of file + assert bob_username in members From 6d36a53b0cc29f196b3734c9fd642e5acfb32d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Srokosz?= Date: Fri, 3 Nov 2023 14:03:43 +0100 Subject: [PATCH 13/14] Convert to TSX --- mwdb/web/src/commons/navigation/AppRoutes.tsx | 2 +- .../{GroupJoinView.jsx => Views/GroupJoinView.tsx} | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename mwdb/web/src/components/{GroupJoinView.jsx => Views/GroupJoinView.tsx} (88%) diff --git a/mwdb/web/src/commons/navigation/AppRoutes.tsx b/mwdb/web/src/commons/navigation/AppRoutes.tsx index 83febd8d9..97a642179 100644 --- a/mwdb/web/src/commons/navigation/AppRoutes.tsx +++ b/mwdb/web/src/commons/navigation/AppRoutes.tsx @@ -62,7 +62,7 @@ import { UploadConfigView } from "@mwdb-web/components/Upload/Views/UploadConfig import { UploadBlobView } from "@mwdb-web/components/Upload/Views/UploadBlobView"; import { UploadFileView } from "@mwdb-web/components/Upload/Views/UploadFileView"; import { SearchView } from "@mwdb-web/components/Views/SearchView"; -import { GroupJoinView } from "@mwdb-web/components/GroupJoinView"; +import { GroupJoinView } from "@mwdb-web/components/Views/GroupJoinView"; export function AppRoutes() { return ( diff --git a/mwdb/web/src/components/GroupJoinView.jsx b/mwdb/web/src/components/Views/GroupJoinView.tsx similarity index 88% rename from mwdb/web/src/components/GroupJoinView.jsx rename to mwdb/web/src/components/Views/GroupJoinView.tsx index 0f9ccea9a..a62a43883 100644 --- a/mwdb/web/src/components/GroupJoinView.jsx +++ b/mwdb/web/src/components/Views/GroupJoinView.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { api } from "@mwdb-web/commons/api"; import { ConfirmationModal } from "@mwdb-web/commons/ui"; import { getErrorMessage } from "@mwdb-web/commons/helpers"; import { toast } from "react-toastify"; export function GroupJoinView() { - const token = new URLSearchParams(window.location.search).get("token"); - const [groupName, setGroupName] = useState(""); + const params = useSearchParams()[0]; + const token = params.get("token") ?? ""; + const [groupName, setGroupName] = useState(""); const navigate = useNavigate(); async function getInvitationDetails() { @@ -29,7 +30,6 @@ export function GroupJoinView() { }); navigate("/"); } catch (error) { - console.log(error); toast(getErrorMessage(error), { type: "error", }); From 9276ad42c49b42c5e925c9f6d4958388b6dacb77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Srokosz?= Date: Wed, 8 Nov 2023 17:50:07 +0100 Subject: [PATCH 14/14] First round of fixes --- mwdb/app.py | 8 +- mwdb/model/user.py | 11 +- mwdb/resources/group.py | 105 ++++++++---------- mwdb/schema/group.py | 4 + mwdb/templates/mail/group_invitation.txt | 5 + mwdb/templates/mail/invitation.txt | 5 - mwdb/web/src/commons/api/index.tsx | 2 +- mwdb/web/src/commons/navigation/AppRoutes.tsx | 2 +- .../src/components/Views/GroupJoinView.tsx | 8 +- 9 files changed, 70 insertions(+), 80 deletions(-) create mode 100644 mwdb/templates/mail/group_invitation.txt delete mode 100644 mwdb/templates/mail/invitation.txt diff --git a/mwdb/app.py b/mwdb/app.py index 88586f628..592946914 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -47,11 +47,11 @@ FileResource, ) from mwdb.resources.group import ( + GroupInviteResource, + GroupJoinResource, GroupListResource, GroupMemberResource, GroupResource, - JoinGroupInviteLinkResource, - RequestGroupInviteLinkResource, ) from mwdb.resources.karton import KartonAnalysisResource, KartonObjectResource from mwdb.resources.metakey import ( @@ -359,8 +359,8 @@ def require_auth(): api.add_resource(GroupListResource, "/group") api.add_resource(GroupResource, "/group/") api.add_resource(GroupMemberResource, "/group//member/") -api.add_resource(RequestGroupInviteLinkResource, "/group//invite/") -api.add_resource(JoinGroupInviteLinkResource, "/group/join") +api.add_resource(GroupInviteResource, "/group//invite/") +api.add_resource(GroupJoinResource, "/group/join") # OAuth endpoints if app_config.mwdb.enable_oidc: diff --git a/mwdb/model/user.py b/mwdb/model/user.py index 0f473c977..6bd47bdeb 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -210,7 +210,7 @@ def generate_group_invite_token(self, group_id, expiration): ) @staticmethod - def verify_group_invite_token(token: str) -> Optional[dict]: + def verify_group_invite_token(token: str) -> Optional[Tuple["User", str]]: result = User._verify_token( token, ["identity_ver"], @@ -219,7 +219,8 @@ def verify_group_invite_token(token: str) -> Optional[dict]: if result is None: return None else: - return result[1] + user, data = result + return user, data["group_id"] @staticmethod def verify_session_token(token: str) -> Optional[Tuple["User", Optional[str]]]: @@ -231,7 +232,8 @@ def verify_session_token(token: str) -> Optional[Tuple["User", Optional[str]]]: if result is None: return None else: - return result[0], result[1].get("provider") + user, data = result + return user, data.get("provider") @staticmethod def verify_set_password_token(token: str) -> Optional["User"]: @@ -243,7 +245,8 @@ def verify_set_password_token(token: str) -> Optional["User"]: if result is None: return None else: - return result[0] + user, _ = result + return user @staticmethod def verify_legacy_token(token): diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 8f8f38d71..cf2ee29b1 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -2,13 +2,7 @@ from flask_restful import Resource from sqlalchemy import exists from sqlalchemy.orm import joinedload -from werkzeug.exceptions import ( - BadRequest, - Conflict, - Forbidden, - InternalServerError, - NotFound, -) +from werkzeug.exceptions import Conflict, Forbidden, InternalServerError, NotFound from mwdb.core.capabilities import Capabilities from mwdb.core.config import app_config @@ -19,6 +13,7 @@ from mwdb.schema.group import ( GroupCreateRequestSchema, GroupInvitationLinkResponseSchema, + GroupInviteTokenRequestSchema, GroupItemResponseSchema, GroupListResponseSchema, GroupMemberUpdateRequestSchema, @@ -586,7 +581,7 @@ def delete(self, name, login): @rate_limited_resource -class RequestGroupInviteLinkResource(Resource): +class GroupInviteResource(Resource): @requires_authorization def post(self, name, invited_user): """ @@ -636,7 +631,7 @@ def post(self, name, invited_user): group_obj = (db.session.query(Group).filter(Group.name == name)).first() if group_obj is None: - raise NotFound("Group does not exist or you are not it's member") + raise NotFound("Group does not exist or you are not its member") member_obj = ( db.session.query(Member) @@ -645,7 +640,7 @@ def post(self, name, invited_user): ).first() if member_obj is None: - raise NotFound("Group does not exist or you are not it's member") + raise NotFound("Group does not exist or you are not its member") if not member_obj.group_admin: raise Forbidden("You do not have group_admin role") @@ -676,8 +671,8 @@ def post(self, name, invited_user): try: send_email_notification( - "invitation", - "MWDB: You have been invited to a new group", + "group_invitation", + "You have been invited to a new group in MWDB", invited_user_obj.email, base_url=app_config.mwdb.base_url, login=invited_user_obj.login, @@ -697,14 +692,14 @@ def post(self, name, invited_user): @rate_limited_resource -class JoinGroupInviteLinkResource(Resource): +class GroupJoinResource(Resource): @requires_authorization - def post(selt): + def get(self): """ --- - summary: Join group using invitation link + summary: Get information about group from invitation token description: | - Join group using link + Get information about group from invitation token security: - bearerAuth: [] @@ -718,7 +713,7 @@ def post(selt): - group responses: 200: - description: When user joined group successfully + description: When data was read successfully content: application/json: schema: GroupSuccessResponseSchema @@ -726,55 +721,35 @@ def post(selt): description: When request body is invalid 403: description: When there was a problem with the token - 409: - description: When user is already a member of this group 503: description: | Request canceled due to database statement timeout. """ - token = request.args.get("token") - - if token is None: - raise BadRequest("Token not found") - - token_data = User.verify_group_invite_token(token) + args = load_schema(request.args, GroupInviteTokenRequestSchema()) + token_data = User.verify_group_invite_token(args["token"]) if not token_data: - raise BadRequest("There was a problem while processing your token") + raise Forbidden( + "Token expired, please re-request invitation to the group administrator" + ) - invited_user_login = token_data.get("login") - if not g.auth_user.login == invited_user_login: + invited_user, group_id = token_data + if g.auth_user.id != invited_user.id: raise Forbidden("This invitation is not for you") - group_id = token_data.get("group_id") - if group_id is None: - raise BadRequest("Invalid token") - - member = ( - db.session.query(Member) - .filter(Member.group_id == group_id) - .filter(Member.user_id == g.auth_user.id) - ).first() - - if member is not None: - raise Conflict("You are already member of this group") - group_obj = db.session.query(Group).filter(Group.id == group_id).first() if group_obj is None: - raise NotFound("This group does not exist") - - group_obj.add_member(g.auth_user) - db.session.commit() + raise NotFound("Group does not exist") schema = GroupSuccessResponseSchema() return schema.dump({"name": group_obj.name}) @requires_authorization - def put(self): + def post(selt): """ --- - summary: Get information about group from invitation token + summary: Join group using invitation link description: | - Get information about group from invitation token + Join group using link security: - bearerAuth: [] @@ -788,7 +763,7 @@ def put(self): - group responses: 200: - description: When data was read successfully + description: When user joined group successfully content: application/json: schema: GroupSuccessResponseSchema @@ -796,30 +771,38 @@ def put(self): description: When request body is invalid 403: description: When there was a problem with the token + 409: + description: When user is already a member of this group 503: description: | Request canceled due to database statement timeout. """ - token = request.args.get("token") - - if token is None: - raise BadRequest("Token not found") - - token_data = User.verify_group_invite_token(token) + args = load_schema(request.args, GroupInviteTokenRequestSchema()) + token_data = User.verify_group_invite_token(args["token"]) if not token_data: - raise Forbidden("There was a problem while processing your token") + raise Forbidden( + "Token expired, please re-request invitation to the group administrator" + ) - invited_user_login = token_data.get("login") - if not g.auth_user.login == invited_user_login: + invited_user, group_id = token_data + if g.auth_user.id != invited_user.id: raise Forbidden("This invitation is not for you") - group_id = token_data.get("group_id") - if group_id is None: - raise Forbidden("Invalid token") + member = ( + db.session.query(Member) + .filter(Member.group_id == group_id) + .filter(Member.user_id == g.auth_user.id) + ).first() + + if member is not None: + raise Conflict("You are already member of this group") group_obj = db.session.query(Group).filter(Group.id == group_id).first() if group_obj is None: raise NotFound("This group does not exist") + group_obj.add_member(g.auth_user) + db.session.commit() + schema = GroupSuccessResponseSchema() return schema.dump({"name": group_obj.name}) diff --git a/mwdb/schema/group.py b/mwdb/schema/group.py index 635909838..0f21d47d6 100644 --- a/mwdb/schema/group.py +++ b/mwdb/schema/group.py @@ -40,6 +40,10 @@ class GroupMemberUpdateRequestSchema(Schema): group_admin = fields.Boolean(required=True) +class GroupInviteTokenRequestSchema(Schema): + token = fields.Str(required=True) + + class GroupBasicResponseSchema(GroupNameSchemaBase): capabilities = fields.List(fields.Str(), required=True, allow_none=False) private = fields.Boolean(required=True) diff --git a/mwdb/templates/mail/group_invitation.txt b/mwdb/templates/mail/group_invitation.txt new file mode 100644 index 000000000..d30511a66 --- /dev/null +++ b/mwdb/templates/mail/group_invitation.txt @@ -0,0 +1,5 @@ +Hi {login}, + +You have been invited to join a new group. + +To view the invitation click this link: {base_url}/profile/group/join?token={group_invite_token} \ No newline at end of file diff --git a/mwdb/templates/mail/invitation.txt b/mwdb/templates/mail/invitation.txt deleted file mode 100644 index 903d3c985..000000000 --- a/mwdb/templates/mail/invitation.txt +++ /dev/null @@ -1,5 +0,0 @@ -Hi {login} - -You have been invited to join new group. - -To view the invitation click this link: {base_url}/group/invite?token={group_invite_token} \ No newline at end of file diff --git a/mwdb/web/src/commons/api/index.tsx b/mwdb/web/src/commons/api/index.tsx index 15f51495a..828e43f56 100644 --- a/mwdb/web/src/commons/api/index.tsx +++ b/mwdb/web/src/commons/api/index.tsx @@ -429,7 +429,7 @@ function requestGroupInviteLink(name: string, invited_user: string) { } function getInvitationData(token: string) { - return axios.put(`/group/join?token=${token}`); + return axios.get(`/group/join?token=${token}`); } function acceptGroupInvitation(token: string) { diff --git a/mwdb/web/src/commons/navigation/AppRoutes.tsx b/mwdb/web/src/commons/navigation/AppRoutes.tsx index 97a642179..291717fd4 100644 --- a/mwdb/web/src/commons/navigation/AppRoutes.tsx +++ b/mwdb/web/src/commons/navigation/AppRoutes.tsx @@ -120,6 +120,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } @@ -184,7 +185,6 @@ export function AppRoutes() { } /> } /> - } /> }> } /> { - navigate("/"); + navigate("/profile/groups"); }} onConfirm={acceptInvitation} >