diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py index bde7dbce..bb653693 100644 --- a/atst/domain/invitations.py +++ b/atst/domain/invitations.py @@ -99,3 +99,8 @@ class Invitations(object): db.session.commit() return invite + + @classmethod + def revoke(cls, token): + invite = Invitations._get(token) + return Invitations._update_status(invite, InvitationStatus.REVOKED) diff --git a/atst/domain/workspaces/workspaces.py b/atst/domain/workspaces/workspaces.py index c92513ab..453a1eed 100644 --- a/atst/domain/workspaces/workspaces.py +++ b/atst/domain/workspaces/workspaces.py @@ -50,6 +50,18 @@ class Workspaces(object): return workspace + @classmethod + def get_for_update_member(cls, user, workspace_id): + workspace = WorkspacesQuery.get(workspace_id) + Authorization.check_workspace_permission( + user, + workspace, + Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE, + "update a workspace member", + ) + + return workspace + @classmethod def get_by_request(cls, request): return WorkspacesQuery.get_by_request(request) diff --git a/atst/models/workspace_role.py b/atst/models/workspace_role.py index bdb8dc18..93a4a85b 100644 --- a/atst/models/workspace_role.py +++ b/atst/models/workspace_role.py @@ -51,7 +51,9 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): if self.status == Status.ACTIVE: return "Active" elif self.latest_invitation: - if self.latest_invitation.is_rejected_expired: + if self.latest_invitation.is_revoked: + return "Revoked" + elif self.latest_invitation.is_rejected_expired: return "Invite expired" elif self.latest_invitation.is_rejected_wrong_user: return "Error on invite" diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index 8a1aa05a..a557473d 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -357,10 +357,18 @@ def update_member(workspace_id, member_id): ) -@bp.route("/workspaces/invitation/", methods=["GET"]) +@bp.route("/workspaces/invitations/", methods=["GET"]) def accept_invitation(token): invite = Invitations.accept(g.current_user, token) return redirect( url_for("workspaces.show_workspace", workspace_id=invite.workspace.id) ) + + +@bp.route("/workspaces//invitations//revoke", methods=["POST"]) +def revoke_invitation(workspace_id, token): + workspace = Workspaces.get_for_update_member(g.current_user, workspace_id) + Invitations.revoke(token) + + return redirect(url_for("workspaces.workspace_members", workspace_id=workspace.id)) diff --git a/templates/components/confirmation_button.html b/templates/components/confirmation_button.html new file mode 100644 index 00000000..71b15a3b --- /dev/null +++ b/templates/components/confirmation_button.html @@ -0,0 +1,19 @@ +{% macro ConfirmationButton(btn_text, action, csrf_token, confirm_msg="Are you sure?", confirm_btn="Confirm", cancel_btn="Cancel") -%} + + + + +{%- endmacro %} diff --git a/templates/workspaces/members/edit.html b/templates/workspaces/members/edit.html index 2dc8dcf2..e840ffef 100644 --- a/templates/workspaces/members/edit.html +++ b/templates/workspaces/members/edit.html @@ -5,6 +5,7 @@ {% from "components/selector.html" import Selector %} {% from "components/options_input.html" import OptionsInput %} {% from "components/alert.html" import Alert %} +{% from "components/confirmation_button.html" import ConfirmationButton %} {% block content %} @@ -38,6 +39,13 @@ {% if editable %} edit account details {% endif %} + {% if member.latest_invitation.is_pending %} + {{ ConfirmationButton( + "Revoke Invitation", + url_for("workspaces.revoke_invitation", workspace_id=workspace.id, token=member.latest_invitation.token), + form.csrf_token + ) }} + {% endif %} diff --git a/tests/domain/test_invitations.py b/tests/domain/test_invitations.py index 2ae38776..6f5a0093 100644 --- a/tests/domain/test_invitations.py +++ b/tests/domain/test_invitations.py @@ -93,3 +93,13 @@ def test_accept_invitation_twice(): Invitations.accept(user, invite.token) with pytest.raises(InvitationError): Invitations.accept(user, invite.token) + + +def test_revoke_invitation(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) + invite = Invitations.create(ws_role, workspace.owner, user) + assert invite.is_pending + Invitations.revoke(invite.token) + assert invite.is_revoked diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index 9e8d3342..8d30058f 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -432,3 +432,28 @@ def test_user_accepts_expired_invite(client, user_session): response = client.get(url_for("workspaces.accept_invitation", token=invite.token)) assert response.status_code == 404 + + +def test_revoke_invitation(client, user_session): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + ws_role = WorkspaceRoleFactory.create( + user=user, workspace=workspace, status=WorkspaceRoleStatus.PENDING + ) + invite = InvitationFactory.create( + user_id=user.id, + workspace_role_id=ws_role.id, + status=InvitationStatus.REJECTED_EXPIRED, + expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1), + ) + user_session(workspace.owner) + response = client.post( + url_for( + "workspaces.revoke_invitation", + workspace_id=workspace.id, + token=invite.token, + ) + ) + + assert response.status_code == 302 + assert invite.is_revoked