1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 09:08:32 +00:00

Optionally limit number of workspaces invites

This commit is contained in:
Bram Wiepjes 2024-05-27 14:44:29 +00:00
parent 2ac9de368f
commit 90e523efae
11 changed files with 143 additions and 1 deletions
backend
src/baserow
api/workspaces/invitations
config/settings
core
tests/baserow
changelog/entries/unreleased/feature
web-frontend
locales
modules/core

View file

@ -10,3 +10,9 @@ ERROR_GROUP_INVITATION_EMAIL_MISMATCH = (
HTTP_400_BAD_REQUEST, HTTP_400_BAD_REQUEST,
"Your email address does not match with the invitation.", "Your email address does not match with the invitation.",
) )
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED = (
"ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED",
HTTP_400_BAD_REQUEST,
"The maximum number of pending invites for this workspace has been reached. "
"Please wait for some invitees to accept the invite or cancel the existing ones.",
)

View file

@ -26,6 +26,7 @@ from baserow.api.schemas import get_error_schema
from baserow.api.workspaces.invitations.errors import ( from baserow.api.workspaces.invitations.errors import (
ERROR_GROUP_INVITATION_DOES_NOT_EXIST, ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
ERROR_GROUP_INVITATION_EMAIL_MISMATCH, ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED,
) )
from baserow.api.workspaces.serializers import WorkspaceUserWorkspaceSerializer from baserow.api.workspaces.serializers import WorkspaceUserWorkspaceSerializer
from baserow.api.workspaces.users.errors import ERROR_GROUP_USER_ALREADY_EXISTS from baserow.api.workspaces.users.errors import ERROR_GROUP_USER_ALREADY_EXISTS
@ -39,6 +40,7 @@ from baserow.core.actions import (
) )
from baserow.core.exceptions import ( from baserow.core.exceptions import (
BaseURLHostnameNotAllowed, BaseURLHostnameNotAllowed,
MaxNumberOfPendingWorkspaceInvitesReached,
UserInvalidWorkspacePermissionsError, UserInvalidWorkspacePermissionsError,
UserNotInWorkspace, UserNotInWorkspace,
WorkspaceDoesNotExist, WorkspaceDoesNotExist,
@ -155,6 +157,7 @@ class WorkspaceInvitationsView(APIView, SortableViewMixin, SearchableViewMixin):
"ERROR_USER_NOT_IN_GROUP", "ERROR_USER_NOT_IN_GROUP",
"ERROR_USER_INVALID_GROUP_PERMISSIONS", "ERROR_USER_INVALID_GROUP_PERMISSIONS",
"ERROR_REQUEST_BODY_VALIDATION", "ERROR_REQUEST_BODY_VALIDATION",
"ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED",
] ]
), ),
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]), 404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
@ -169,6 +172,7 @@ class WorkspaceInvitationsView(APIView, SortableViewMixin, SearchableViewMixin):
UserInvalidWorkspacePermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS, UserInvalidWorkspacePermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS,
WorkspaceUserAlreadyExists: ERROR_GROUP_USER_ALREADY_EXISTS, WorkspaceUserAlreadyExists: ERROR_GROUP_USER_ALREADY_EXISTS,
BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED, BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED,
MaxNumberOfPendingWorkspaceInvitesReached: ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED,
} }
) )
def post(self, request, data, workspace_id): def post(self, request, data, workspace_id):

View file

@ -1058,6 +1058,11 @@ BASEROW_USER_LOG_ENTRY_CLEANUP_INTERVAL_MINUTES = int(
BASEROW_USER_LOG_ENTRY_RETENTION_DAYS = int( BASEROW_USER_LOG_ENTRY_RETENTION_DAYS = int(
os.getenv("BASEROW_USER_LOG_ENTRY_RETENTION_DAYS", 61) os.getenv("BASEROW_USER_LOG_ENTRY_RETENTION_DAYS", 61)
) )
# The maximum number of pending invites that a workspace can have. If `0` then
# unlimited invites are allowed, which is the default value.
BASEROW_MAX_PENDING_WORKSPACE_INVITES = int(
os.getenv("BASEROW_MAX_PENDING_WORKSPACE_INVITES", 0)
)
PERMISSION_MANAGERS = [ PERMISSION_MANAGERS = [

View file

@ -76,6 +76,13 @@ class WorkspaceUserAlreadyExists(Exception):
""" """
class MaxNumberOfPendingWorkspaceInvitesReached(Exception):
"""
Raised when the maximum number of pending workspace invites has been reached.
This value is configurable via the `BASEROW_MAX_PENDING_WORKSPACE_INVITES` setting.
"""
class WorkspaceUserIsLastAdmin(Exception): class WorkspaceUserIsLastAdmin(Exception):
""" """
Raised when the last admin of the workspace tries to leave it. This will leave the Raised when the last admin of the workspace tries to leave it. This will leave the

View file

@ -33,6 +33,7 @@ from .exceptions import (
DuplicateApplicationMaxLocksExceededException, DuplicateApplicationMaxLocksExceededException,
InvalidPermissionContext, InvalidPermissionContext,
LastAdminOfWorkspace, LastAdminOfWorkspace,
MaxNumberOfPendingWorkspaceInvitesReached,
PermissionDenied, PermissionDenied,
PermissionException, PermissionException,
TemplateDoesNotExist, TemplateDoesNotExist,
@ -1039,6 +1040,8 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
:raises ValueError: If the provided permissions are not allowed. :raises ValueError: If the provided permissions are not allowed.
:raises UserInvalidWorkspacePermissionsError: If the user does not belong to the :raises UserInvalidWorkspacePermissionsError: If the user does not belong to the
workspace or doesn't have right permissions in the workspace. workspace or doesn't have right permissions in the workspace.
:raises MaxNumberOfPendingWorkspaceInvitesReached: When the maximum number of
pending invites have been reached.
:return: The created workspace invitation. :return: The created workspace invitation.
:rtype: WorkspaceInvitation :rtype: WorkspaceInvitation
""" """
@ -1059,6 +1062,18 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
f"The user {email} is already part of the workspace." f"The user {email} is already part of the workspace."
) )
max_invites = settings.BASEROW_MAX_PENDING_WORKSPACE_INVITES
if max_invites > 0 and (
WorkspaceInvitation.objects.filter(workspace=workspace)
.exclude(email=email)
.count()
>= max_invites
):
raise MaxNumberOfPendingWorkspaceInvitesReached(
f"The maximum number of pending workspaces invites {max_invites} has "
f"been reached."
)
invitation, created = WorkspaceInvitation.objects.update_or_create( invitation, created = WorkspaceInvitation.objects.update_or_create(
workspace=workspace, workspace=workspace,
email=email, email=email,

View file

@ -1,4 +1,5 @@
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test.utils import override_settings
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
@ -255,6 +256,48 @@ def test_create_workspace_invitation(api_client, data_fixture):
assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION" assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION"
@pytest.mark.django_db
@override_settings(BASEROW_MAX_PENDING_WORKSPACE_INVITES=1)
def test_create_workspace_invitation_max_pending(api_client, data_fixture):
user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl")
workspace_1 = data_fixture.create_workspace(user=user_1)
response = api_client.post(
reverse(
"api:workspaces:invitations:list", kwargs={"workspace_id": workspace_1.id}
),
{
"email": "test@test.nl",
"permissions": "ADMIN",
"message": "Test",
"base_url": "http://localhost:3000/invite",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
assert response.status_code == HTTP_200_OK
response = api_client.post(
reverse(
"api:workspaces:invitations:list", kwargs={"workspace_id": workspace_1.id}
),
{
"email": "test2@test.nl",
"permissions": "ADMIN",
"message": "Test",
"base_url": "http://localhost:3000/invite",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert (
response_json["error"]
== "ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED"
)
@pytest.mark.django_db @pytest.mark.django_db
def test_get_workspace_invitation(api_client, data_fixture): def test_get_workspace_invitation(api_client, data_fixture):
user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl") user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl")

View file

@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
from django.conf import settings from django.conf import settings
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.db import OperationalError, transaction from django.db import OperationalError, transaction
from django.test.utils import override_settings
import pytest import pytest
from itsdangerous.exc import BadSignature from itsdangerous.exc import BadSignature
@ -28,6 +29,7 @@ from baserow.core.exceptions import (
DuplicateApplicationMaxLocksExceededException, DuplicateApplicationMaxLocksExceededException,
IsNotAdminError, IsNotAdminError,
LastAdminOfWorkspace, LastAdminOfWorkspace,
MaxNumberOfPendingWorkspaceInvitesReached,
TemplateDoesNotExist, TemplateDoesNotExist,
TemplateFileDoesNotExist, TemplateFileDoesNotExist,
UserInvalidWorkspacePermissionsError, UserInvalidWorkspacePermissionsError,
@ -702,6 +704,47 @@ def test_create_workspace_invitation(mock_send_email, data_fixture):
assert WorkspaceInvitation.objects.all().count() == 3 assert WorkspaceInvitation.objects.all().count() == 3
@pytest.mark.django_db
@patch("baserow.core.handler.CoreHandler.send_workspace_invitation_email")
@override_settings(BASEROW_MAX_PENDING_WORKSPACE_INVITES=1)
def test_create_workspace_invitation_max_pending(mock_send_email, data_fixture):
user_workspace = data_fixture.create_user_workspace()
user = user_workspace.user
workspace = user_workspace.workspace
handler = CoreHandler()
handler.create_workspace_invitation(
user=user,
workspace=workspace,
email="test@test.nl",
permissions="ADMIN",
message="Test",
base_url="http://localhost:3000/invite",
)
with pytest.raises(MaxNumberOfPendingWorkspaceInvitesReached):
handler.create_workspace_invitation(
user=user,
workspace=workspace,
email="test2@test.nl",
permissions="ADMIN",
message="Test",
base_url="http://localhost:3000/invite",
)
# This email address already exists, so it should just update the invite without
# failing.
handler.create_workspace_invitation(
user=user,
workspace=workspace,
email="test@test.nl",
permissions="MEMBER",
message="Test",
base_url="http://localhost:3000/invite",
)
@pytest.mark.django_db @pytest.mark.django_db
def test_update_workspace_invitation(data_fixture): def test_update_workspace_invitation(data_fixture):
workspace_invitation = data_fixture.create_workspace_invitation() workspace_invitation = data_fixture.create_workspace_invitation()

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Optionally limit the number of pending invites using the `BASEROW_MAX_PENDING_WORKSPACE_INVITES` environment variable.",
"issue_number": null,
"bullet_points": [],
"created_at": "2024-05-27"
}

View file

@ -330,7 +330,9 @@
"outputParserTitle": "Wrong output", "outputParserTitle": "Wrong output",
"outputParserDescription": "The model responded with an incorrect output. Please try again.", "outputParserDescription": "The model responded with an incorrect output. Please try again.",
"generateAIPromptTitle": "Prompt error", "generateAIPromptTitle": "Prompt error",
"generateAIPromptDescription": "Something was wrong with the constructed prompt." "generateAIPromptDescription": "Something was wrong with the constructed prompt.",
"maxNumberOfPendingWorkspaceInvitesReachedTitle": "Max pending invites reached",
"maxNumberOfPendingWorkspaceInvitesReachedDescription": "You've reached the maximum number of pending invites for this workspace. Please let invitees accept the invite or cancel existing ones to continue."
}, },
"importerType": { "importerType": {
"csv": "Import a CSV file", "csv": "Import a CSV file",

View file

@ -55,6 +55,7 @@ export default {
methods: { methods: {
async inviteSubmitted(values) { async inviteSubmitted(values) {
this.inviteLoading = true this.inviteLoading = true
this.hideError()
try { try {
// The public accept url is the page where the user can publicly navigate too, // The public accept url is the page where the user can publicly navigate too,

View file

@ -163,6 +163,15 @@ export class ClientErrorMap {
app.i18n.t('clientHandler.modelDoesNotBelongToTypeTitle'), app.i18n.t('clientHandler.modelDoesNotBelongToTypeTitle'),
app.i18n.t('clientHandler.modelDoesNotBelongToTypeDescription') app.i18n.t('clientHandler.modelDoesNotBelongToTypeDescription')
), ),
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED:
new ResponseErrorMessage(
app.i18n.t(
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedTitle'
),
app.i18n.t(
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedDescription'
)
),
} }
} }