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:
parent
2ac9de368f
commit
90e523efae
11 changed files with 143 additions and 1 deletions
backend
src/baserow
tests/baserow
changelog/entries/unreleased/feature
web-frontend
|
@ -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.",
|
||||||
|
)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'
|
||||||
|
)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue