1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 22:35:36 +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,
"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 (
ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED,
)
from baserow.api.workspaces.serializers import WorkspaceUserWorkspaceSerializer
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 (
BaseURLHostnameNotAllowed,
MaxNumberOfPendingWorkspaceInvitesReached,
UserInvalidWorkspacePermissionsError,
UserNotInWorkspace,
WorkspaceDoesNotExist,
@ -155,6 +157,7 @@ class WorkspaceInvitationsView(APIView, SortableViewMixin, SearchableViewMixin):
"ERROR_USER_NOT_IN_GROUP",
"ERROR_USER_INVALID_GROUP_PERMISSIONS",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED",
]
),
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
@ -169,6 +172,7 @@ class WorkspaceInvitationsView(APIView, SortableViewMixin, SearchableViewMixin):
UserInvalidWorkspacePermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS,
WorkspaceUserAlreadyExists: ERROR_GROUP_USER_ALREADY_EXISTS,
BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED,
MaxNumberOfPendingWorkspaceInvitesReached: ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED,
}
)
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(
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 = [

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):
"""
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,
InvalidPermissionContext,
LastAdminOfWorkspace,
MaxNumberOfPendingWorkspaceInvitesReached,
PermissionDenied,
PermissionException,
TemplateDoesNotExist,
@ -1039,6 +1040,8 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
:raises ValueError: If the provided permissions are not allowed.
:raises UserInvalidWorkspacePermissionsError: If the user does not belong to the
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.
:rtype: WorkspaceInvitation
"""
@ -1059,6 +1062,18 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
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(
workspace=workspace,
email=email,

View file

@ -1,4 +1,5 @@
from django.shortcuts import reverse
from django.test.utils import override_settings
import pytest
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"
@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
def test_get_workspace_invitation(api_client, data_fixture):
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.core.files.storage import FileSystemStorage
from django.db import OperationalError, transaction
from django.test.utils import override_settings
import pytest
from itsdangerous.exc import BadSignature
@ -28,6 +29,7 @@ from baserow.core.exceptions import (
DuplicateApplicationMaxLocksExceededException,
IsNotAdminError,
LastAdminOfWorkspace,
MaxNumberOfPendingWorkspaceInvitesReached,
TemplateDoesNotExist,
TemplateFileDoesNotExist,
UserInvalidWorkspacePermissionsError,
@ -702,6 +704,47 @@ def test_create_workspace_invitation(mock_send_email, data_fixture):
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
def test_update_workspace_invitation(data_fixture):
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",
"outputParserDescription": "The model responded with an incorrect output. Please try again.",
"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": {
"csv": "Import a CSV file",

View file

@ -55,6 +55,7 @@ export default {
methods: {
async inviteSubmitted(values) {
this.inviteLoading = true
this.hideError()
try {
// 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.modelDoesNotBelongToTypeDescription')
),
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED:
new ResponseErrorMessage(
app.i18n.t(
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedTitle'
),
app.i18n.t(
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedDescription'
)
),
}
}