diff --git a/backend/src/baserow/api/serializers.py b/backend/src/baserow/api/serializers.py index 309d2f657b..4391be6f89 100644 --- a/backend/src/baserow/api/serializers.py +++ b/backend/src/baserow/api/serializers.py @@ -85,7 +85,7 @@ def to_internal_value(self, data): natural_key = super().to_internal_value(data) try: - return self._model.objects.get_by_natural_key(*natural_key) + return self.get_queryset().get_by_natural_key(*natural_key) except self._model.DoesNotExist as e: if self._custom_does_not_exist_exception_class: raise self._custom_does_not_exist_exception_class( @@ -94,6 +94,9 @@ def to_internal_value(self, data): else: raise e + def get_queryset(self): + return self._model.objects + class CommaSeparatedIntegerValuesField(serializers.Field): """A serializer field that accepts a CSV string containing a list of integers.""" diff --git a/backend/src/baserow/api/user/errors.py b/backend/src/baserow/api/user/errors.py index f17a5638e1..124180c174 100644 --- a/backend/src/baserow/api/user/errors.py +++ b/backend/src/baserow/api/user/errors.py @@ -79,3 +79,15 @@ HTTP_400_BAD_REQUEST, "The provided refresh token is already blacklisted.", ) + +ERROR_CHANGE_EMAIL_NOT_ALLOWED = ( + "ERROR_CHANGE_EMAIL_NOT_ALLOWED", + HTTP_400_BAD_REQUEST, + "Email changes are only allowed for password-based accounts.", +) + +ERROR_EMAIL_ALREADY_CHANGED = ( + "ERROR_EMAIL_ALREADY_CHANGED", + HTTP_400_BAD_REQUEST, + "The email address has already been changed to the requested address.", +) diff --git a/backend/src/baserow/api/user/serializers.py b/backend/src/baserow/api/user/serializers.py index 6accc5a664..d433179d57 100755 --- a/backend/src/baserow/api/user/serializers.py +++ b/backend/src/baserow/api/user/serializers.py @@ -246,6 +246,22 @@ class ChangePasswordBodyValidationSerializer(serializers.Serializer): new_password = serializers.CharField(validators=[password_validation]) +class SendChangeEmailConfirmationSerializer(serializers.Serializer): + new_email = serializers.EmailField(help_text="The new email address to change to.") + password = serializers.CharField( + help_text="The current password of the user for verification." + ) + base_url = serializers.URLField( + help_text="The base URL where the user can confirm the email change. The " + "confirmation token is going to be appended to the base_url " + "(base_url '/token')." + ) + + +class ChangeEmailSerializer(serializers.Serializer): + token = serializers.CharField(help_text="The confirmation token.") + + class VerifyEmailAddressSerializer(serializers.Serializer): token = serializers.CharField() diff --git a/backend/src/baserow/api/user/urls.py b/backend/src/baserow/api/user/urls.py index cc493aca58..af93b1f476 100644 --- a/backend/src/baserow/api/user/urls.py +++ b/backend/src/baserow/api/user/urls.py @@ -3,6 +3,7 @@ from .views import ( AccountView, BlacklistJSONWebToken, + ChangeEmailView, ChangePasswordView, DashboardView, ObtainJSONWebToken, @@ -10,6 +11,7 @@ RefreshJSONWebToken, ResetPasswordView, ScheduleAccountDeletionView, + SendChangeEmailConfirmationView, SendResetPasswordEmailView, SendVerifyEmailView, ShareOnboardingDetailsWithBaserowView, @@ -43,6 +45,12 @@ re_path( r"^change-password/$", ChangePasswordView.as_view(), name="change_password" ), + re_path( + r"^send-change-email-confirmation/$", + SendChangeEmailConfirmationView.as_view(), + name="send_change_email_confirmation", + ), + re_path(r"^change-email/$", ChangeEmailView.as_view(), name="change_email"), re_path( r"^send-verify-email/$", SendVerifyEmailView.as_view(), name="send_verify_email" ), diff --git a/backend/src/baserow/api/user/views.py b/backend/src/baserow/api/user/views.py index e806926757..a7c349c11b 100755 --- a/backend/src/baserow/api/user/views.py +++ b/backend/src/baserow/api/user/views.py @@ -57,18 +57,22 @@ from baserow.core.handler import CoreHandler from baserow.core.models import Settings, Template, WorkspaceInvitation from baserow.core.user.actions import ( + ChangeEmailActionType, ChangeUserPasswordActionType, CreateUserActionType, ResetUserPasswordActionType, ScheduleUserDeletionActionType, + SendChangeEmailConfirmationActionType, SendResetUserPasswordActionType, SendVerifyEmailAddressActionType, UpdateUserActionType, VerifyEmailAddressActionType, ) from baserow.core.user.exceptions import ( + ChangeEmailNotAllowed, DeactivatedUserException, DisabledSignupError, + EmailAlreadyChanged, EmailAlreadyVerified, InvalidPassword, InvalidVerificationToken, @@ -84,10 +88,12 @@ from .errors import ( ERROR_ALREADY_EXISTS, ERROR_AUTH_PROVIDER_DISABLED, + ERROR_CHANGE_EMAIL_NOT_ALLOWED, ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET, ERROR_DEACTIVATED_USER, ERROR_DISABLED_RESET_PASSWORD, ERROR_DISABLED_SIGNUP, + ERROR_EMAIL_ALREADY_CHANGED, ERROR_EMAIL_ALREADY_VERIFIED, ERROR_EMAIL_VERIFICATION_REQUIRED, ERROR_INVALID_CREDENTIALS, @@ -107,10 +113,12 @@ ) from .serializers import ( AccountSerializer, + ChangeEmailSerializer, ChangePasswordBodyValidationSerializer, DashboardSerializer, RegisterSerializer, ResetPasswordBodyValidationSerializer, + SendChangeEmailConfirmationSerializer, SendResetPasswordEmailBodyValidationSerializer, SendVerifyEmailAddressSerializer, ShareOnboardingDetailsWithBaserowSerializer, @@ -482,6 +490,104 @@ def post(self, request, data): return Response(status=204) +class SendChangeEmailConfirmationView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + tags=["User"], + request=SendChangeEmailConfirmationSerializer, + operation_id="send_change_email_confirmation", + description=( + "Sends an email to the new email address containing a confirmation link. " + "The user must provide their current password to initiate this request. " + f"The link is going to be valid for " + f"{int(settings.CHANGE_EMAIL_TOKEN_MAX_AGE / 60 / 60)} hours." + ), + responses={ + 204: None, + 400: get_error_schema( + [ + "ERROR_REQUEST_BODY_VALIDATION", + "ERROR_HOSTNAME_IS_NOT_ALLOWED", + "ERROR_INVALID_OLD_PASSWORD", + "ERROR_ALREADY_EXISTS", + "ERROR_CHANGE_EMAIL_NOT_ALLOWED", + "ERROR_AUTH_PROVIDER_DISABLED", + ] + ), + }, + ) + @transaction.atomic + @map_exceptions( + { + BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED, + InvalidPassword: ERROR_INVALID_OLD_PASSWORD, + UserAlreadyExist: ERROR_ALREADY_EXISTS, + AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED, + ChangeEmailNotAllowed: ERROR_CHANGE_EMAIL_NOT_ALLOWED, + } + ) + @validate_body(SendChangeEmailConfirmationSerializer) + def post(self, request, data): + """ + Sends a confirmation email to the new email address if the password is correct. + """ + + action_type_registry.get(SendChangeEmailConfirmationActionType.type).do( + request.user, data["new_email"], data["password"], data["base_url"] + ) + + return Response(status=204) + + +class ChangeEmailView(APIView): + permission_classes = (AllowAny,) + + @extend_schema( + tags=["User"], + request=ChangeEmailSerializer, + operation_id="change_email", + description=( + "Changes the email address of a user if the confirmation token is valid. " + "The **send_change_email_confirmation** endpoint sends an email to the " + "new address containing the token. That token can be used to change the " + "email address here." + ), + responses={ + 204: None, + 400: get_error_schema( + [ + "BAD_TOKEN_SIGNATURE", + "EXPIRED_TOKEN_SIGNATURE", + "ERROR_USER_NOT_FOUND", + "ERROR_ALREADY_EXISTS", + "ERROR_EMAIL_ALREADY_CHANGED", + "ERROR_REQUEST_BODY_VALIDATION", + ] + ), + }, + auth=[], + ) + @transaction.atomic + @map_exceptions( + { + BadSignature: BAD_TOKEN_SIGNATURE, + BadTimeSignature: BAD_TOKEN_SIGNATURE, + SignatureExpired: EXPIRED_TOKEN_SIGNATURE, + UserNotFound: ERROR_USER_NOT_FOUND, + UserAlreadyExist: ERROR_ALREADY_EXISTS, + EmailAlreadyChanged: ERROR_EMAIL_ALREADY_CHANGED, + } + ) + @validate_body(ChangeEmailSerializer) + def post(self, request, data): + """Changes the user's email address if the provided token is valid.""" + + action_type_registry.get(ChangeEmailActionType.type).do(data["token"]) + + return Response(status=204) + + class AccountView(APIView): permission_classes = (IsAuthenticated,) diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index fc7c8fba66..3a2542305d 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -779,6 +779,7 @@ def __setitem__(self, key, value): FROM_EMAIL = os.getenv("FROM_EMAIL", "no-reply@localhost") RESET_PASSWORD_TOKEN_MAX_AGE = 60 * 60 * 48 # 48 hours +CHANGE_EMAIL_TOKEN_MAX_AGE = 60 * 60 * 12 # 12 hours ROW_PAGE_SIZE_LIMIT = int(os.getenv("BASEROW_ROW_PAGE_SIZE_LIMIT", 200)) BATCH_ROWS_SIZE_LIMIT = int( diff --git a/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po index 717d594c98..f67ca384e9 100644 --- a/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-17 15:17+0000\n" +"POT-Creation-Date: 2025-11-25 13:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,7 @@ msgstr "" msgid "Last name" msgstr "" -#: src/baserow/contrib/builder/data_providers/data_provider_types.py:619 +#: src/baserow/contrib/builder/data_providers/data_provider_types.py:621 #, python-format msgid "%(user_source_name)s member" msgstr "" diff --git a/backend/src/baserow/contrib/database/airtable/import_report.py b/backend/src/baserow/contrib/database/airtable/import_report.py index 0c0947fe61..132684ed22 100644 --- a/backend/src/baserow/contrib/database/airtable/import_report.py +++ b/backend/src/baserow/contrib/database/airtable/import_report.py @@ -12,6 +12,7 @@ from baserow.contrib.database.views.models import GridView from baserow.contrib.database.views.registries import view_type_registry from baserow.core.constants import BASEROW_COLORS +from baserow.core.registries import ImportExportConfig REPORT_TABLE_ID = "report" REPORT_TABLE_NAME = "Airtable import report" @@ -99,7 +100,11 @@ def get_baserow_export_table(self, order: int) -> dict: grid_view.get_field_options = lambda *args, **kwargs: [] grid_view_type = view_type_registry.get_by_model(grid_view) empty_serialized_grid_view = grid_view_type.export_serialized( - grid_view, None, None, None + grid_view, + ImportExportConfig(include_permission_data=False), + None, + None, + None, ) empty_serialized_grid_view["id"] = 0 exported_views = [empty_serialized_grid_view] diff --git a/backend/src/baserow/contrib/database/airtable/registry.py b/backend/src/baserow/contrib/database/airtable/registry.py index 2ee34d1f2a..ad5f0126ef 100644 --- a/backend/src/baserow/contrib/database/airtable/registry.py +++ b/backend/src/baserow/contrib/database/airtable/registry.py @@ -811,7 +811,7 @@ def to_serialized_baserow_view( config, import_report, ) - serialized = view_type.export_serialized(view) + serialized = view_type.export_serialized(view, config) return serialized diff --git a/backend/src/baserow/contrib/database/application_types.py b/backend/src/baserow/contrib/database/application_types.py index 770957a5c8..f59f730ed9 100755 --- a/backend/src/baserow/contrib/database/application_types.py +++ b/backend/src/baserow/contrib/database/application_types.py @@ -135,7 +135,9 @@ def export_tables_serialized( view = v.specific view_type = view_type_registry.get_by_model(view) serialized_views.append( - view_type.export_serialized(view, table_cache, files_zip, storage) + view_type.export_serialized( + view, import_export_config, table_cache, files_zip, storage + ) ) serialized_rows = [] @@ -564,7 +566,9 @@ def import_tables_serialized( # Now that the all tables and fields exist, we can create the views and create # the table schema in the database. for serialized_table in serialized_tables: - self._import_table_views(serialized_table, id_mapping, files_zip, progress) + self._import_table_views( + serialized_table, import_export_config, id_mapping, files_zip, progress + ) self._create_table_schema( serialized_table, already_created_through_table_names ) @@ -910,6 +914,7 @@ def _create_table_schema( def _import_table_views( self, serialized_table: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, progress: Optional[ChildProgressBuilder] = None, @@ -929,7 +934,9 @@ def _import_table_views( table_name = serialized_table["name"] for serialized_view in serialized_table["views"]: view_type = view_type_registry.get(serialized_view["type"]) - view_type.import_serialized(table, serialized_view, id_mapping, files_zip) + view_type.import_serialized( + table, serialized_view, import_export_config, id_mapping, files_zip + ) progress.increment( state=f"{IMPORT_SERIALIZED_IMPORTING_TABLE_STRUCTURE}{table_name}" ) diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 92d7272635..115bc16131 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -88,6 +88,7 @@ from baserow.core.exceptions import PermissionDenied from baserow.core.handler import CoreHandler from baserow.core.models import Workspace +from baserow.core.registries import ImportExportConfig from baserow.core.telemetry.utils import baserow_trace_methods from baserow.core.trash.handler import TrashHandler from baserow.core.utils import ( @@ -898,6 +899,7 @@ def create_view( ) view_type.view_created(view=instance) + view_ownership_type.view_created(user=user, view=instance, workspace=workspace) view_created.send(self, view=instance, user=user, type_name=type_name) return instance @@ -938,12 +940,18 @@ def duplicate_view(self, user: AbstractUser, original_view: View) -> View: view_type = view_type_registry.get_by_model(original_view) + config = ImportExportConfig( + include_permission_data=True, + reduce_disk_space_usage=False, + is_duplicate=True, + ) + cache = { "workspace_id": workspace.id, } # Use export/import to duplicate the view easily - serialized = view_type.export_serialized(original_view, cache) + serialized = view_type.export_serialized(original_view, config, cache) # Change the name of the view serialized["name"] = self.find_unused_view_name( @@ -967,7 +975,7 @@ def duplicate_view(self, user: AbstractUser, original_view: View) -> View: "database_field_select_options": MirrorDict(), } duplicated_view = view_type.import_serialized( - original_view.table, serialized, id_mapping + original_view.table, serialized, config, id_mapping ) if duplicated_view is None: diff --git a/backend/src/baserow/contrib/database/views/registries.py b/backend/src/baserow/contrib/database/views/registries.py index 8c23ef9b2d..780ae035e4 100644 --- a/backend/src/baserow/contrib/database/views/registries.py +++ b/backend/src/baserow/contrib/database/views/registries.py @@ -25,7 +25,11 @@ from baserow.core.exceptions import PermissionDenied from baserow.core.handler import CoreHandler from baserow.core.models import Workspace, WorkspaceUser -from baserow.core.registries import OperationType +from baserow.core.registries import ( + ImportExportConfig, + OperationType, + serialization_processor_registry, +) from baserow.core.registry import ( APIUrlsInstanceMixin, APIUrlsRegistryMixin, @@ -219,6 +223,7 @@ def __init__(self, *args, **kwargs): def export_serialized( self, view: "View", + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ExportZipFile] = None, storage: Optional[Storage] = None, @@ -228,6 +233,9 @@ def export_serialized( `import_serialized` method. This dict is also JSON serializable. :param view: The view instance that must be exported. + :param import_export_config: provides configuration options for the + import/export process to customize how it works. + :param cache: A cache to use for storing temporary data. :param files_zip: A zip file buffer where the files related to the export must be copied into. :param storage: The storage where the files can be loaded from. @@ -298,12 +306,24 @@ def export_serialized( if self.can_share: serialized["public"] = view.public + # It could be that there is no `table` related to the view when doing an + # Airtable export, for example. That means it's not part of a workspace, so we + # can't enhance the export with the `serialization_processor_registry`. + if view.table_id is not None: + for serialized_structure in serialization_processor_registry.get_all(): + extra_data = serialized_structure.export_serialized( + view.table.database.workspace, view, import_export_config + ) + if extra_data is not None: + serialized.update(**extra_data) + return serialized def import_serialized( self, table: "Table", serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -316,6 +336,8 @@ def import_serialized( :param table: The table where the view should be added to. :param serialized_values: The exported serialized view values that need to be imported. + :param import_export_config: provides configuration options for the + import/export process to customize how it works. :param id_mapping: The map of exported ids to newly created ids that must be updated when a new instance has been created. :param files_zip: A zip file buffer where files related to the export can be @@ -399,7 +421,15 @@ def import_serialized( decorations = ( serialized_copy.pop("decorations", []) if self.can_decorate else [] ) - view = self.model_class.objects.create(table=table, **serialized_copy) + + view = self.model_class(table=table) + # Only set the properties that are actually accepted by the view type's model + # class. + for key, value in serialized_copy.items(): + if hasattr(view, key): + setattr(view, key, value) + view.save() + id_mapping["database_views"][view_id] = view.id if self.can_filter: @@ -493,6 +523,13 @@ def import_serialized( view_decoration_id ] = view_decoration_object.id + for ( + serialized_structure_processor + ) in serialization_processor_registry.get_all(): + serialized_structure_processor.import_serialized( + table.database.workspace, view, serialized_copy, import_export_config + ) + return view def get_visible_fields_and_model( @@ -1406,6 +1443,16 @@ class (f. ex. `CollaborativeViewOwnershipType`, raise PermissionDenied() + def view_created(self, user: AbstractUser, view: "View", workspace: Workspace): + """ + Hook that is called after a view is created. This can be used to introduce + additional permissions checks, for example. + + :param user: The user that created the view. + :param view: The view that was created. + :param workspace: The workspace where the view was created in. + """ + class ViewOwnershipTypeRegistry(Registry): """ diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py index a2f2eab2d4..e0b966ef8d 100644 --- a/backend/src/baserow/contrib/database/views/view_types.py +++ b/backend/src/baserow/contrib/database/views/view_types.py @@ -48,6 +48,7 @@ from baserow.contrib.database.views.registries import view_aggregation_type_registry from baserow.core.handler import CoreHandler from baserow.core.import_export.utils import file_chunk_generator +from baserow.core.registries import ImportExportConfig from baserow.core.storage import ExportZipFile from baserow.core.user_files.handler import UserFileHandler from baserow.core.user_files.models import UserFile @@ -108,6 +109,7 @@ def get_api_urls(self): def export_serialized( self, grid: View, + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ExportZipFile] = None, storage: Optional[Storage] = None, @@ -116,7 +118,9 @@ def export_serialized( Adds the serialized grid view options to the exported dict. """ - serialized = super().export_serialized(grid, cache, files_zip, storage) + serialized = super().export_serialized( + grid, import_export_config, cache, files_zip, storage + ) serialized["row_identifier_type"] = grid.row_identifier_type serialized["row_height_size"] = grid.row_height_size @@ -141,6 +145,7 @@ def import_serialized( self, table: Table, serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -152,7 +157,7 @@ def import_serialized( serialized_copy = serialized_values.copy() field_options = serialized_copy.pop("field_options") grid_view = super().import_serialized( - table, serialized_copy, id_mapping, files_zip, storage + table, serialized_copy, import_export_config, id_mapping, files_zip, storage ) if grid_view is not None: if "database_grid_view_field_options" not in id_mapping: @@ -417,6 +422,7 @@ def after_fields_type_change(self, fields): def export_serialized( self, gallery: View, + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ExportZipFile] = None, storage: Optional[Storage] = None, @@ -425,7 +431,9 @@ def export_serialized( Adds the serialized gallery view options to the exported dict. """ - serialized = super().export_serialized(gallery, cache, files_zip, storage) + serialized = super().export_serialized( + gallery, import_export_config, cache, files_zip, storage + ) if gallery.card_cover_image_field_id: serialized["card_cover_image_field_id"] = gallery.card_cover_image_field_id @@ -448,6 +456,7 @@ def import_serialized( self, table: Table, serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -466,7 +475,7 @@ def import_serialized( field_options = serialized_copy.pop("field_options") gallery_view = super().import_serialized( - table, serialized_copy, id_mapping, files_zip, storage + table, serialized_copy, import_export_config, id_mapping, files_zip, storage ) if gallery_view is not None: @@ -1110,6 +1119,7 @@ def _update_field_options_allowed_select_options( def export_serialized( self, form: View, + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ExportZipFile] = None, storage: Optional[Storage] = None, @@ -1118,7 +1128,9 @@ def export_serialized( Adds the serialized form view options to the exported dict. """ - serialized = super().export_serialized(form, cache, files_zip, storage) + serialized = super().export_serialized( + form, import_export_config, cache, files_zip, storage + ) def add_user_file(user_file): if not user_file: @@ -1195,6 +1207,7 @@ def import_serialized( self, table: Table, serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -1221,7 +1234,7 @@ def get_file(file): serialized_copy["logo_image"] = get_file(serialized_copy.pop("logo_image")) field_options = serialized_copy.pop("field_options") form_view = super().import_serialized( - table, serialized_copy, id_mapping, files_zip, storage + table, serialized_copy, import_export_config, id_mapping, files_zip, storage ) if form_view is not None: diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py index 88ad043caf..66ceb9e842 100755 --- a/backend/src/baserow/core/apps.py +++ b/backend/src/baserow/core/apps.py @@ -323,10 +323,12 @@ def ready(self): from baserow.core.user.actions import ( CancelUserDeletionActionType, + ChangeEmailActionType, ChangeUserPasswordActionType, CreateUserActionType, ResetUserPasswordActionType, ScheduleUserDeletionActionType, + SendChangeEmailConfirmationActionType, SendResetUserPasswordActionType, SendVerifyEmailAddressActionType, SignInUserActionType, @@ -344,6 +346,8 @@ def ready(self): action_type_registry.register(ResetUserPasswordActionType()) action_type_registry.register(SendVerifyEmailAddressActionType()) action_type_registry.register(VerifyEmailAddressActionType()) + action_type_registry.register(SendChangeEmailConfirmationActionType()) + action_type_registry.register(ChangeEmailActionType()) from baserow.core.action.scopes import ( ApplicationActionScopeType, diff --git a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po index 3c42d63ebd..54a6c36099 100644 --- a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-17 15:17+0000\n" +"POT-Creation-Date: 2025-11-25 13:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,8 +108,8 @@ msgstr "" #, python-format msgid "" "Application \"%(application_name)s\" (%(application_id)s) of type " -"%(application_type)s duplicated from \"%(original_application_name)s\" " -"(%(original_application_id)s)" +"%(application_type)s duplicated from " +"\"%(original_application_name)s\" (%(original_application_id)s)" msgstr "" #: src/baserow/core/actions.py:709 @@ -130,8 +130,8 @@ msgstr "" #: src/baserow/core/actions.py:796 #, python-format msgid "" -"Group invitation created for \"%(email)s\" to join \"%(group_name)s\" " -"(%(group_id)s) as %(permissions)s." +"Group invitation created for \"%(email)s\" to join " +"\"%(group_name)s\" (%(group_id)s) as %(permissions)s." msgstr "" #: src/baserow/core/actions.py:851 @@ -242,7 +242,7 @@ msgstr "" msgid "Decimal number" msgstr "" -#: src/baserow/core/handler.py:2187 src/baserow/core/user/handler.py:267 +#: src/baserow/core/handler.py:2187 src/baserow/core/user/handler.py:269 #, python-format msgid "%(name)s's workspace" msgstr "" @@ -341,6 +341,7 @@ msgstr[1] "" #: src/baserow/core/templates/baserow/core/user/account_deleted.html:188 #: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:188 #: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:193 +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:211 #: src/baserow/core/templates/baserow/core/user/reset_password.html:211 #: src/baserow/core/templates/baserow/core/workspace_invitation.html:215 msgid "" @@ -389,6 +390,30 @@ msgid "" "just have to login again." msgstr "" +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:176 +msgid "Confirm email address change" +msgstr "" + +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:181 +#, python-format +msgid "" +"A request was made to change the email address for your Baserow account from " +"%(old_email)s to %(new_email)s on Baserow " +"(%(baserow_embedded_share_hostname)s). If you did not authorize this, you " +"may simply ignore this email." +msgstr "" + +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:186 +#, python-format +msgid "" +"To confirm your email address change, simply click the button below. This " +"link will expire in %(hours)s hours." +msgstr "" + +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:195 +msgid "Confirm email change" +msgstr "" + #: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:181 msgid "Thank you for using Baserow" msgstr "" @@ -496,8 +521,9 @@ msgstr "" #: src/baserow/core/user/actions.py:27 #, python-format msgid "" -"User \"%(user_email)s\" (%(user_id)s) created via \"%(auth_provider_type)s\" " -"(%(auth_provider_id)s) auth provider (invitation: %(with_invitation_token)s)" +"User \"%(user_email)s\" (%(user_id)s) created via " +"\"%(auth_provider_type)s\" (%(auth_provider_id)s) auth provider (invitation: " +"%(with_invitation_token)s)" msgstr "" #: src/baserow/core/user/actions.py:119 @@ -587,6 +613,28 @@ msgstr "" msgid "User \"%(user_email)s\" (%(user_id)s) verify email" msgstr "" +#: src/baserow/core/user/actions.py:510 +msgid "Send change email confirmation" +msgstr "" + +#: src/baserow/core/user/actions.py:512 +#, python-format +msgid "" +"User \"%(user_email)s\" (%(user_id)s) requested to change email to " +"\"%(new_email)s\"" +msgstr "" + +#: src/baserow/core/user/actions.py:558 +msgid "Change email" +msgstr "" + +#: src/baserow/core/user/actions.py:560 +#, python-format +msgid "" +"User \"%(old_email)s\" (%(user_id)s) changed email to \"%(new_email)s\" by " +"using the token." +msgstr "" + #: src/baserow/core/user/emails.py:16 msgid "Reset password - Baserow" msgstr "" @@ -602,3 +650,7 @@ msgstr "" #: src/baserow/core/user/emails.py:74 msgid "Account deletion cancelled - Baserow" msgstr "" + +#: src/baserow/core/user/emails.py:94 +msgid "Confirm email address change - Baserow" +msgstr "" diff --git a/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html b/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html new file mode 100644 index 0000000000..2edf70aff1 --- /dev/null +++ b/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html @@ -0,0 +1,229 @@ +{% load i18n %} + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+ + + + + + +
+
{{ logo_additional_text }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + {% if show_baserow_description %} + + + + + {% endif %} + + +
+
{% trans "Confirm email address change" %}
+
+
{% blocktrans trimmed with user.username as old_email and new_email as new_email and baserow_embedded_share_hostname as baserow_embedded_share_hostname %} A request was made to change the email address for your Baserow account from {{ old_email }} to {{ new_email }} on Baserow ({{ baserow_embedded_share_hostname }}). If you did not authorize this, you may simply ignore this email. {% endblocktrans %}
+
+
{% blocktrans trimmed with expire_hours|floatformat:"0" as hours %} To confirm your email address change, simply click the button below. This link will expire in {{ hours }} hours. {% endblocktrans %}
+
+ + + + + + +
+ {% trans "Confirm email change" %} +
+
+
{{ confirmation_url }}
+
+
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.mjml.eta b/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.mjml.eta new file mode 100644 index 0000000000..06f198c8f9 --- /dev/null +++ b/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.mjml.eta @@ -0,0 +1,35 @@ +<% layout("../../base.layout.eta") %> + + + + {% trans "Confirm email address change" %} + + {% blocktrans trimmed with user.username as old_email and new_email as new_email and baserow_embedded_share_hostname as baserow_embedded_share_hostname %} + A request was made to change the email address for your Baserow account from + {{ old_email }} to {{ new_email }} on Baserow ({{ baserow_embedded_share_hostname }}). + If you did not authorize this, you may simply ignore this email. + {% endblocktrans %} + + + {% blocktrans trimmed with expire_hours|floatformat:"0" as hours %} + To confirm your email address change, simply click the button below. + This link will expire in {{ hours }} hours. + {% endblocktrans %} + + + {% trans "Confirm email change" %} + + + {{ confirmation_url }} + + {% if show_baserow_description %} + + {% blocktrans trimmed %} + Baserow is an open source no-code database tool which allows you to collaborate + on projects, customers and more. It gives you the powers of a developer without + leaving your browser. + {% endblocktrans %} + + {% endif %} + + diff --git a/backend/src/baserow/core/types.py b/backend/src/baserow/core/types.py index 5205c339fd..8438f53d35 100644 --- a/backend/src/baserow/core/types.py +++ b/backend/src/baserow/core/types.py @@ -4,10 +4,10 @@ from django.contrib.auth.models import AbstractUser # noqa: F401 from django.contrib.auth.models import AnonymousUser # noqa: F401 - from baserow.contrib.automation.models import Automation - from baserow.contrib.builder.models import Builder - from baserow.contrib.dashboard.models import Dashboard - from baserow.contrib.database.models import Database, Table + from baserow.contrib.automation.models import Automation # noqa: F401 + from baserow.contrib.builder.models import Builder # noqa: F401 + from baserow.contrib.dashboard.models import Dashboard # noqa: F401 + from baserow.contrib.database.models import Database, Table, View # noqa: F401 # A scope object needs to have a related registered ScopeObjectType ScopeObject = Any @@ -24,7 +24,7 @@ # Objects which can be exported and imported in a `SerializationProcessorType`. SerializationProcessorScope = Union[ - "Database", "Table", "Builder", "Dashboard", "Automation" + "Database", "Table", "View", "Builder", "Dashboard", "Automation" ] diff --git a/backend/src/baserow/core/user/actions.py b/backend/src/baserow/core/user/actions.py index 618d18b849..80654900cd 100644 --- a/backend/src/baserow/core/user/actions.py +++ b/backend/src/baserow/core/user/actions.py @@ -502,3 +502,94 @@ def do(cls, verification_token: str): @classmethod def scope(cls) -> ActionScopeStr: return RootActionScopeType.value() + + +class SendChangeEmailConfirmationActionType(ActionType): + type = "send_change_email_confirmation" + description = ActionTypeDescription( + _("Send change email confirmation"), + _( + 'User "%(user_email)s" (%(user_id)s) requested to change email to ' + '"%(new_email)s"' + ), + ) + analytics_params = [ + "user_id", + ] + + @dataclasses.dataclass + class Params: + user_id: int + user_email: str + new_email: str + + @classmethod + def do( + cls, user: AbstractUser, new_email: str, password: str, base_url: str + ) -> AbstractUser: + """ + Send a change email confirmation email to the new email address. + + :param user: The user that requested the email change. + :param new_email: The new email address. + :param password: The current password for verification. + :param base_url: The base URL for the confirmation link. + """ + + UserHandler().send_change_email_confirmation( + user, new_email, password, base_url + ) + + cls.register_action( + user=user, + params=cls.Params(user.id, user.email, new_email), + scope=cls.scope(), + ) + return user + + @classmethod + def scope(cls) -> ActionScopeStr: + return RootActionScopeType.value() + + +class ChangeEmailActionType(ActionType): + type = "change_email" + description = ActionTypeDescription( + _("Change email"), + _( + 'User "%(old_email)s" (%(user_id)s) changed email to "%(new_email)s" by ' + "using the token." + ), + ) + analytics_params = [ + "user_id", + ] + + @dataclasses.dataclass + class Params: + user_id: int + old_email: str + new_email: str + + @classmethod + def do(cls, token: str) -> AbstractUser: + """ + Change the user's email address if the provided confirmation token is valid. + + :param token: The confirmation token to check whether it's valid. + :return: The updated user object. + """ + + handler = UserHandler() + user, old_email = handler.change_email(token) + + cls.register_action( + user=user, + params=cls.Params(user.id, old_email, user.email), + scope=cls.scope(), + ) + return user + + @classmethod + def scope(cls) -> ActionScopeStr: + return RootActionScopeType.value() diff --git a/backend/src/baserow/core/user/emails.py b/backend/src/baserow/core/user/emails.py index 18776a4788..4083842958 100644 --- a/backend/src/baserow/core/user/emails.py +++ b/backend/src/baserow/core/user/emails.py @@ -79,3 +79,26 @@ def get_context(self): user=self.user, ) return context + + +class ChangeEmailConfirmationEmail(BaseEmailMessage): + template_name = "baserow/core/user/change_email_confirmation.html" + + def __init__(self, user, new_email, confirmation_url, *args, **kwargs): + self.user = user + self.new_email = new_email + self.confirmation_url = confirmation_url + super().__init__(*args, **kwargs) + + def get_subject(self): + return _("Confirm email address change - Baserow") + + def get_context(self): + context = super().get_context() + context.update( + user=self.user, + new_email=self.new_email, + confirmation_url=self.confirmation_url, + expire_hours=settings.CHANGE_EMAIL_TOKEN_MAX_AGE / 60 / 60, + ) + return context diff --git a/backend/src/baserow/core/user/exceptions.py b/backend/src/baserow/core/user/exceptions.py index d1d06a65b9..fd64a95c30 100644 --- a/backend/src/baserow/core/user/exceptions.py +++ b/backend/src/baserow/core/user/exceptions.py @@ -44,3 +44,11 @@ class DeactivatedUserException(Exception): class RefreshTokenAlreadyBlacklisted(Exception): """Raised when the provided refresh token is already blacklisted.""" + + +class ChangeEmailNotAllowed(Exception): + """Raised when a user without a password tries to change their email.""" + + +class EmailAlreadyChanged(Exception): + """Raised when the email has already been changed to the requested address.""" diff --git a/backend/src/baserow/core/user/handler.py b/backend/src/baserow/core/user/handler.py index f56624d633..49ed4b22c4 100755 --- a/backend/src/baserow/core/user/handler.py +++ b/backend/src/baserow/core/user/handler.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Optional, Tuple from urllib.parse import urljoin, urlparse from django.conf import settings @@ -19,6 +19,7 @@ from opentelemetry import trace from requests.exceptions import RequestException +from baserow.core.auth_provider.exceptions import AuthProviderDisabled from baserow.core.auth_provider.handler import PasswordProviderHandler from baserow.core.auth_provider.models import AuthProviderModel from baserow.core.emails import EmailPendingVerificationEmail @@ -53,11 +54,14 @@ AccountDeleted, AccountDeletionCanceled, AccountDeletionScheduled, + ChangeEmailConfirmationEmail, ResetPasswordEmail, ) from .exceptions import ( + ChangeEmailNotAllowed, DeactivatedUserException, DisabledSignupError, + EmailAlreadyChanged, EmailAlreadyVerified, InvalidPassword, InvalidVerificationToken, @@ -379,6 +383,16 @@ def get_reset_password_signer(self) -> URLSafeTimedSerializer: return URLSafeTimedSerializer(settings.SECRET_KEY, "user-reset-password") + def get_change_email_signer(self) -> URLSafeTimedSerializer: + """ + Instantiates the email change serializer that can dump and load values. + + :return: The itsdangerous serializer. + :rtype: URLSafeTimedSerializer + """ + + return URLSafeTimedSerializer(settings.SECRET_KEY, "user-change-email") + def send_reset_password_email(self, user: AbstractUser, base_url: str): """ Sends an email containing a password reset url to the user. @@ -496,6 +510,115 @@ def change_password( return user + def send_change_email_confirmation( + self, user: AbstractUser, new_email: str, password: str, base_url: str + ): + """ + Sends an email containing an email change confirmation url to the new email + address. + + :param user: The user where to change the email for. + :param new_email: The new email address to change to. + :param password: The current password of the user for verification. + :param base_url: The base url of the frontend, where the user can confirm the + email change. The confirmation token is appended to the URL + (base_url + '/TOKEN'). Only the PUBLIC_WEB_FRONTEND_HOSTNAME is allowed + as domain name. + :raises InvalidPassword: When the provided password is incorrect. + :raises UserAlreadyExist: When a user with the new email already exists. + :raises BaseURLHostnameNotAllowed: When the provided base_url hostname is not + allowed. + :raises AuthProviderDisabled: When the password provider is disabled and the + user is not staff. + :raises ChangeEmailNotAllowed: When the user does not have a password set. + """ + + # Email changes are only for password-based accounts. Accounts that don't + # have password set are accounts created via SSO. + if user.password == "": # nosec + raise ChangeEmailNotAllowed() + + if not PasswordProviderHandler.get().enabled: + raise AuthProviderDisabled() + + if not user.check_password(password): + raise InvalidPassword("The provided password is incorrect.") + + new_email = normalize_email_address(new_email) + + # Check if a user with the new email already exists + if User.objects.filter(Q(email=new_email) | Q(username=new_email)).exists(): + raise UserAlreadyExist(f"A user with email {new_email} already exists.") + + parsed_base_url = urlparse(base_url) + if parsed_base_url.hostname not in ( + settings.PUBLIC_WEB_FRONTEND_HOSTNAME, + settings.BASEROW_EMBEDDED_SHARE_HOSTNAME, + ): + raise BaseURLHostnameNotAllowed( + f"The hostname {parsed_base_url.netloc} is not allowed." + ) + + signer = self.get_change_email_signer() + signed_payload = signer.dumps({"user_id": user.id, "new_email": new_email}) + + if not base_url.endswith("/"): + base_url += "/" + + confirmation_url = urljoin(base_url, signed_payload) + + with translation.override(user.profile.language): + email = ChangeEmailConfirmationEmail( + user, new_email, confirmation_url, to=[new_email] + ) + email.send() + + def change_email(self, token: str) -> Tuple[AbstractUser, str]: + """ + Changes the email address of a user if the provided token is valid. + + :param token: The signed token that was sent to the new email address. + :raises SignatureExpired: When the provided token's signature has expired. + :raises UserNotFound: When a user related to the provided token has not been + found. + :raises UserAlreadyExist: When a user with the new email already exists. + :raises EmailAlreadyChanged: When the email has already been changed to the + requested address. + :return: A tuple containing the updated user instance and the old email address. + """ + + signer = self.get_change_email_signer() + payload = signer.loads(token, max_age=settings.CHANGE_EMAIL_TOKEN_MAX_AGE) + + user_id = payload["user_id"] + new_email = normalize_email_address(payload["new_email"]) + + user = self.get_active_user(user_id=user_id) + old_email = normalize_email_address(user.email) + + # Check if the new email is the same as the current email + if old_email == new_email: + raise EmailAlreadyChanged( + f"The email address has already been changed to the requested address." + ) + + # Check again if a user with the new email already exists because it could be + # that a new user was created in the meantime. + if ( + User.objects.filter(Q(email=new_email) | Q(username=new_email)) + .exclude(id=user.id) + .exists() + ): + raise UserAlreadyExist(f"A user with email {new_email} already exists.") + + user.email = new_email + user.username = new_email + user.save() + + user_updated.send(self, performed_by=user, user=user) + + return user, old_email + def user_signed_in_via_provider( self, user: AbstractUser, authentication_provider: AuthProviderModel ): diff --git a/backend/tests/baserow/api/users/test_user_views.py b/backend/tests/baserow/api/users/test_user_views.py index dde1e0ce7d..a707256980 100755 --- a/backend/tests/baserow/api/users/test_user_views.py +++ b/backend/tests/baserow/api/users/test_user_views.py @@ -1261,3 +1261,240 @@ def test_share_onboarding_details_with_baserow(mock_task, client, data_fixture): size="11 - 50", country="The Netherlands", ) + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation(data_fixture, client, mailoutbox): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user, token = data_fixture.create_user_and_token( + email="test@test.nl", password=valid_password + ) + + response = client.post( + reverse("api:user:send_change_email_confirmation"), + {}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION" + + response = client.post( + reverse("api:user:send_change_email_confirmation"), + { + "new_email": "newemail@test.nl", + "password": "wrongpassword", + "base_url": f"{settings.PUBLIC_WEB_FRONTEND_URL}/change-email", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_INVALID_OLD_PASSWORD" + + data_fixture.create_user(email="existing@test.nl") + response = client.post( + reverse("api:user:send_change_email_confirmation"), + { + "new_email": "existing@test.nl", + "password": valid_password, + "base_url": f"{settings.PUBLIC_WEB_FRONTEND_URL}/change-email", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_EMAIL_ALREADY_EXISTS" + + response = client.post( + reverse("api:user:send_change_email_confirmation"), + { + "new_email": "newemail@test.nl", + "password": valid_password, + "base_url": f"{settings.PUBLIC_WEB_FRONTEND_URL}/change-email", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + assert len(mailoutbox) == 1 + assert mailoutbox[0].to[0] == "newemail@test.nl" + assert "Confirm email address change" in mailoutbox[0].subject + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation_password_auth_disabled( + data_fixture, client, mailoutbox +): + data_fixture.create_password_provider(enabled=False) + valid_password = "thisIsAValidPassword" + user, token = data_fixture.create_user_and_token( + email="test@test.nl", password=valid_password + ) + + response = client.post( + reverse("api:user:send_change_email_confirmation"), + { + "new_email": "newemail@test.nl", + "password": valid_password, + "base_url": f"{settings.PUBLIC_WEB_FRONTEND_URL}/change-email", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json() == { + "error": "ERROR_AUTH_PROVIDER_DISABLED", + "detail": "Authentication provider is disabled.", + } + assert len(mailoutbox) == 0 + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation_without_password( + data_fixture, client, mailoutbox +): + data_fixture.create_password_provider() + user, token = data_fixture.create_user_and_token(email="test@test.nl") + user.password = "" + user.save() + + response = client.post( + reverse("api:user:send_change_email_confirmation"), + { + "new_email": "newemail@test.nl", + "password": "dummy_password", # Provided but user has no password + "base_url": f"{settings.PUBLIC_WEB_FRONTEND_URL}/change-email", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json() == { + "error": "ERROR_CHANGE_EMAIL_NOT_ALLOWED", + "detail": "Email changes are only allowed for password-based accounts.", + } + assert len(mailoutbox) == 0 + + +@pytest.mark.django_db(transaction=True) +def test_change_email(data_fixture, client, mailoutbox): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user, token = data_fixture.create_user_and_token( + email="test@test.nl", password=valid_password + ) + + response = client.post( + reverse("api:user:send_change_email_confirmation"), + { + "new_email": "newemail@test.nl", + "password": valid_password, + "base_url": f"{settings.PUBLIC_WEB_FRONTEND_URL}/change-email", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + assert len(mailoutbox) == 1 + + handler = UserHandler() + signer = handler.get_change_email_signer() + change_token = signer.dumps({"user_id": user.id, "new_email": "newemail@test.nl"}) + + response = client.post( + reverse("api:user:change_email"), + {"token": "invalid_token"}, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "BAD_TOKEN_SIGNATURE" + + response = client.post( + reverse("api:user:change_email"), + {"token": change_token}, + format="json", + ) + assert response.status_code == HTTP_204_NO_CONTENT + + user.refresh_from_db() + assert user.email == "newemail@test.nl" + assert user.username == "newemail@test.nl" + + response = client.post( + reverse("api:user:change_email"), + {"token": change_token}, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_EMAIL_ALREADY_CHANGED" + + user.refresh_from_db() + assert user.email == "newemail@test.nl" + assert user.username == "newemail@test.nl" + + +@pytest.mark.django_db +def test_change_email_with_expired_token(data_fixture, client): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user(email="test@test.nl", password=valid_password) + + handler = UserHandler() + signer = handler.get_change_email_signer() + + with freeze_time("2023-01-01 12:00:00"): + change_token = signer.dumps( + {"user_id": user.id, "new_email": "newemail@test.nl"} + ) + + with freeze_time("2023-01-10 12:00:00"): # More than the token max age + response = client.post( + reverse("api:user:change_email"), + {"token": change_token}, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "EXPIRED_TOKEN_SIGNATURE" + + +@pytest.mark.django_db +def test_change_email_same_as_current(data_fixture, client): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user(email="test@test.nl", password=valid_password) + + handler = UserHandler() + signer = handler.get_change_email_signer() + + change_token = signer.dumps({"user_id": user.id, "new_email": "test@test.nl"}) + response = client.post( + reverse("api:user:change_email"), + {"token": change_token}, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_EMAIL_ALREADY_CHANGED" + + change_token = signer.dumps({"user_id": user.id, "new_email": "TeSt@TeSt.nl"}) + response = client.post( + reverse("api:user:change_email"), + {"token": change_token}, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_EMAIL_ALREADY_CHANGED" + + user.refresh_from_db() + assert user.email == "test@test.nl" diff --git a/backend/tests/baserow/contrib/database/api/views/test_view_views.py b/backend/tests/baserow/contrib/database/api/views/test_view_views.py index 841aedf6f0..12c4d40431 100644 --- a/backend/tests/baserow/contrib/database/api/views/test_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/test_view_views.py @@ -625,7 +625,7 @@ def test_patch_view_field_options_as_template(api_client, data_fixture): @override_settings(PERMISSION_MANAGERS=["basic"]) @pytest.mark.django_db -def test_patch_view_validate_ownerhip_type_invalid_type(api_client, data_fixture): +def test_patch_view_validate_ownership_type_invalid_type(api_client, data_fixture): """A test to make sure that if an invalid `ownership_type` string is passed when updating the view, the `ownership_type` is not updated and this results in status 400 error with an error message. @@ -653,7 +653,8 @@ def test_patch_view_validate_ownerhip_type_invalid_type(api_client, data_fixture assert view.ownership_type == previous_ownership_type assert ( response_data["detail"]["ownership_type"][0]["error"] - == "Ownership type must be one of the above: 'collaborative','personal'." + == "Ownership type must be one of the above: 'collaborative','personal'" + ",'restricted'." ) diff --git a/backend/tests/baserow/contrib/database/view/test_view_types.py b/backend/tests/baserow/contrib/database/view/test_view_types.py index 6e8c602e2a..acb0725969 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_types.py +++ b/backend/tests/baserow/contrib/database/view/test_view_types.py @@ -61,9 +61,16 @@ def test_import_export_grid_view(data_fixture): id_mapping = {"database_fields": {field.id: imported_field.id}} grid_view_type = view_type_registry.get("grid") - serialized = grid_view_type.export_serialized(grid_view, None, None, None) + serialized = grid_view_type.export_serialized( + grid_view, ImportExportConfig(include_permission_data=False), None, None, None + ) imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, id_mapping, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + None, + None, ) assert grid_view.id != imported_grid_view.id @@ -182,7 +189,11 @@ def test_import_export_gallery_view(data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: serialized = gallery_view_type.export_serialized( - gallery_view, None, files_zip=files_zip, storage=storage + gallery_view, + ImportExportConfig(include_permission_data=False), + None, + files_zip=files_zip, + storage=storage, ) assert serialized["id"] == gallery_view.id @@ -207,7 +218,12 @@ def test_import_export_gallery_view(data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_gallery_view = gallery_view_type.import_serialized( - gallery_view.table, serialized, id_mapping, files_zip, storage + gallery_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + files_zip, + storage, ) assert gallery_view.id != imported_gallery_view.id @@ -347,7 +363,11 @@ def test_import_export_form_view(data_fixture, tmpdir): ) serialized = form_view_type.export_serialized( - form_view, None, files_zip=zip_file, storage=storage + form_view, + ImportExportConfig(include_permission_data=False), + None, + files_zip=zip_file, + storage=storage, ) for chunk in zip_file: @@ -409,7 +429,12 @@ def test_import_export_form_view(data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_form_view = form_view_type.import_serialized( - form_view.table, serialized, id_mapping, files_zip, storage + form_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + files_zip, + storage, ) assert form_view.id != imported_form_view.id @@ -572,7 +597,11 @@ def test_import_export_form_view_with_grouped_conditions(data_fixture, tmpdir): ) serialized = form_view_type.export_serialized( - form_view, None, files_zip=zip_file, storage=storage + form_view, + ImportExportConfig(include_permission_data=False), + None, + files_zip=zip_file, + storage=storage, ) for chunk in zip_file: @@ -694,7 +723,12 @@ def test_import_export_form_view_with_grouped_conditions(data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_form_view = form_view_type.import_serialized( - form_view.table, serialized, id_mapping, files_zip, storage + form_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + files_zip, + storage, ) form_expected = { @@ -905,9 +939,16 @@ def test_import_export_view_ownership_type(data_fixture): grid_view.save() grid_view_type = view_type_registry.get("grid") - serialized = grid_view_type.export_serialized(grid_view, None, None) + serialized = grid_view_type.export_serialized( + grid_view, ImportExportConfig(include_permission_data=False), None, None + ) imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, {}, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + {}, + None, + None, ) assert grid_view.id != imported_grid_view.id @@ -919,7 +960,12 @@ def test_import_export_view_ownership_type(data_fixture): WorkspaceUser.objects.filter(user=user2).delete() imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, {}, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + {}, + None, + None, ) assert imported_grid_view is None @@ -929,9 +975,16 @@ def test_import_export_view_ownership_type(data_fixture): grid_view.ownership_type = "collaborative" grid_view.save() - serialized = grid_view_type.export_serialized(grid_view, None, None) + serialized = grid_view_type.export_serialized( + grid_view, ImportExportConfig(include_permission_data=False), None, None + ) imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, {}, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + {}, + None, + None, ) assert grid_view.id != imported_grid_view.id @@ -949,14 +1002,21 @@ def test_import_export_view_ownership_type_created_by_backward_compatible(data_f grid_view = data_fixture.create_grid_view(table=table, owned_by=user2) grid_view_type = view_type_registry.get("grid") - serialized = grid_view_type.export_serialized(grid_view, None, None) + serialized = grid_view_type.export_serialized( + grid_view, ImportExportConfig(include_permission_data=False), None, None + ) # Owned by was called created_by before, so test if everything still works # when importing the old name: serialized["created_by"] = serialized.pop("owned_by") imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, {}, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + {}, + None, + None, ) assert grid_view.owned_by == imported_grid_view.owned_by @@ -982,14 +1042,21 @@ def test_import_export_view_ownership_type_not_in_registry(data_fixture): grid_view.owned_by = user2 grid_view.save() grid_view_type = view_type_registry.get("grid") - serialized = grid_view_type.export_serialized(grid_view, None, None) + serialized = grid_view_type.export_serialized( + grid_view, ImportExportConfig(include_permission_data=False), None, None + ) with patch( "baserow.contrib.database.views.registries.view_ownership_type_registry.registry", ownership_types, ): imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, {}, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + {}, + None, + None, ) assert imported_grid_view is None diff --git a/backend/tests/baserow/core/mcp/test_mcp_utils.py b/backend/tests/baserow/core/mcp/test_mcp_utils.py index 10cc84f52f..d3d2e8214b 100644 --- a/backend/tests/baserow/core/mcp/test_mcp_utils.py +++ b/backend/tests/baserow/core/mcp/test_mcp_utils.py @@ -98,10 +98,10 @@ def test_polymorphic_serializer_to_openapi_inline(): "x-spec-enum-id": "bc45559484b1f708", }, "ownership_type": { - "enum": ["collaborative", "personal"], + "enum": ["collaborative", "personal", "restricted"], "type": "string", - "description": "* `collaborative` - collaborative\n* `personal` - personal", - "x-spec-enum-id": "d4dd2da3edbad2e6", + "description": "* `collaborative` - collaborative\n* `personal` - personal\n* `restricted` - restricted", + "x-spec-enum-id": "c917b297d9d9ef1b", "default": "collaborative", }, "filter_type": { @@ -156,10 +156,10 @@ def test_polymorphic_serializer_to_openapi_inline(): "x-spec-enum-id": "bc45559484b1f708", }, "ownership_type": { - "enum": ["collaborative", "personal"], + "enum": ["collaborative", "personal", "restricted"], "type": "string", - "description": "* `collaborative` - collaborative\n* `personal` - personal", - "x-spec-enum-id": "d4dd2da3edbad2e6", + "description": "* `collaborative` - collaborative\n* `personal` - personal\n* `restricted` - restricted", + "x-spec-enum-id": "c917b297d9d9ef1b", "default": "collaborative", }, "filter_type": { @@ -207,10 +207,10 @@ def test_polymorphic_serializer_to_openapi_inline(): "x-spec-enum-id": "bc45559484b1f708", }, "ownership_type": { - "enum": ["collaborative", "personal"], + "enum": ["collaborative", "personal", "restricted"], "type": "string", - "description": "* `collaborative` - collaborative\n* `personal` - personal", - "x-spec-enum-id": "d4dd2da3edbad2e6", + "description": "* `collaborative` - collaborative\n* `personal` - personal\n* `restricted` - restricted", + "x-spec-enum-id": "c917b297d9d9ef1b", "default": "collaborative", }, "filter_type": { @@ -396,10 +396,10 @@ def test_polymorphic_serializer_to_openapi_inline(): "x-spec-enum-id": "bc45559484b1f708", }, "ownership_type": { - "enum": ["collaborative", "personal"], + "enum": ["collaborative", "personal", "restricted"], "type": "string", - "description": "* `collaborative` - collaborative\n* `personal` - personal", - "x-spec-enum-id": "d4dd2da3edbad2e6", + "description": "* `collaborative` - collaborative\n* `personal` - personal\n* `restricted` - restricted", + "x-spec-enum-id": "c917b297d9d9ef1b", "default": "collaborative", }, "filter_type": { @@ -448,10 +448,10 @@ def test_polymorphic_serializer_to_openapi_inline(): "x-spec-enum-id": "bc45559484b1f708", }, "ownership_type": { - "enum": ["collaborative", "personal"], + "enum": ["collaborative", "personal", "restricted"], "type": "string", - "description": "* `collaborative` - collaborative\n* `personal` - personal", - "x-spec-enum-id": "d4dd2da3edbad2e6", + "description": "* `collaborative` - collaborative\n* `personal` - personal\n* `restricted` - restricted", + "x-spec-enum-id": "c917b297d9d9ef1b", "default": "collaborative", }, "filter_type": { @@ -505,10 +505,10 @@ def test_polymorphic_serializer_to_openapi_inline(): "x-spec-enum-id": "bc45559484b1f708", }, "ownership_type": { - "enum": ["collaborative", "personal"], + "enum": ["collaborative", "personal", "restricted"], "type": "string", - "description": "* `collaborative` - collaborative\n* `personal` - personal", - "x-spec-enum-id": "d4dd2da3edbad2e6", + "description": "* `collaborative` - collaborative\n* `personal` - personal\n* `restricted` - restricted", + "x-spec-enum-id": "c917b297d9d9ef1b", "default": "collaborative", }, "filter_type": { diff --git a/backend/tests/baserow/core/user/test_user_handler.py b/backend/tests/baserow/core/user/test_user_handler.py index 3adecfa7f0..dd31343abc 100755 --- a/backend/tests/baserow/core/user/test_user_handler.py +++ b/backend/tests/baserow/core/user/test_user_handler.py @@ -837,3 +837,183 @@ def test_share_onboarding_details_with_baserow_valid_response(data_fixture): ) assert response1.call_count == 1 + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation(data_fixture, mailoutbox): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user(email="test@localhost", password=valid_password) + handler = UserHandler() + + # Test with invalid hostname + with pytest.raises(BaseURLHostnameNotAllowed): + handler.send_change_email_confirmation( + user, "newemail@localhost", valid_password, "http://test.nl/change-email" + ) + + # Test with incorrect password + with pytest.raises(InvalidPassword): + handler.send_change_email_confirmation( + user, + "newemail@localhost", + "wrongpassword", + "http://localhost:3000/change-email", + ) + + # Test with existing email + data_fixture.create_user(email="existing@localhost") + with pytest.raises(UserAlreadyExist): + handler.send_change_email_confirmation( + user, + "existing@localhost", + valid_password, + "http://localhost:3000/change-email", + ) + + # Test successful email change confirmation + signer = handler.get_change_email_signer() + handler.send_change_email_confirmation( + user, "newemail@localhost", valid_password, "http://localhost:3000/change-email" + ) + + assert len(mailoutbox) == 1 + email = mailoutbox[0] + + assert email.subject == "Confirm email address change - Baserow" + assert email.from_email == "no-reply@localhost" + assert "newemail@localhost" in email.to + + html_body = email.alternatives[0][0] + search_url = "http://localhost:3000/change-email/" + start_url_index = html_body.index(search_url) + + assert start_url_index != -1 + + end_url_index = html_body.index('"', start_url_index) + token = html_body[start_url_index + len(search_url) : end_url_index] + + payload = signer.loads(token) + assert payload["user_id"] == user.id + assert payload["new_email"] == "newemail@localhost" + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation_in_different_language(data_fixture, mailoutbox): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user( + email="test@localhost", password=valid_password, language="fr" + ) + handler = UserHandler() + + handler.send_change_email_confirmation( + user, "newemail@localhost", valid_password, "http://localhost:3000/change-email" + ) + + assert len(mailoutbox) == 1 + # The French translation for "Confirm email address change - Baserow" + assert ( + "Confirmer le changement" in mailoutbox[0].subject + or "Baserow" in mailoutbox[0].subject + ) + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation_auth_provider_disabled(data_fixture): + from baserow.core.auth_provider.exceptions import AuthProviderDisabled + + data_fixture.create_password_provider(enabled=False) + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user(email="test@localhost", password=valid_password) + + with pytest.raises(AuthProviderDisabled): + UserHandler().send_change_email_confirmation( + user, + "newemail@localhost", + valid_password, + "http://localhost:3000/change-email", + ) + + +@pytest.mark.django_db(transaction=True) +def test_send_change_email_confirmation_without_password(data_fixture): + from baserow.core.user.exceptions import ChangeEmailNotAllowed + + data_fixture.create_password_provider() + # Create user without password (simulating SSO account) + user = data_fixture.create_user(email="test@localhost") + user.password = "" + user.save() + + with pytest.raises(ChangeEmailNotAllowed): + UserHandler().send_change_email_confirmation( + user, + "newemail@localhost", + "dummy_password", # Provided but user has no password + "http://localhost:3000/change-email", + ) + + +@pytest.mark.django_db +def test_change_email(data_fixture): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user(email="test@localhost", password=valid_password) + handler = UserHandler() + + # Test with invalid token + with pytest.raises(BadSignature): + handler.change_email("invalid_token") + + # Test with non-existent user + signer = handler.get_change_email_signer() + with freeze_time("2020-01-01 12:00"): + token = signer.dumps({"user_id": 9999, "new_email": "newemail@localhost"}) + + with freeze_time("2020-01-01 18:00"): # Within 12 hours + with pytest.raises(UserNotFound): + handler.change_email(token) + + # Test with expired token + with freeze_time("2020-01-01 12:00"): + token = signer.dumps({"user_id": user.id, "new_email": "newemail@localhost"}) + + with freeze_time("2020-01-02 01:01"): # More than 12 hours later (13 hours 1 min) + with pytest.raises(SignatureExpired): + handler.change_email(token) + + # Test successful email change + with freeze_time("2020-01-01 12:00"): + token = signer.dumps({"user_id": user.id, "new_email": "newemail@localhost"}) + + with freeze_time("2020-01-01 18:00"): # Within 12 hours + changed_user, old_email = handler.change_email(token) + + assert changed_user.email == "newemail@localhost" + assert changed_user.username == "newemail@localhost" + assert old_email == "test@localhost" + + # Verify the user was updated in the database + user.refresh_from_db() + assert user.email == "newemail@localhost" + assert user.username == "newemail@localhost" + + +@pytest.mark.django_db +def test_change_email_already_exists(data_fixture): + data_fixture.create_password_provider() + valid_password = "thisIsAValidPassword" + user = data_fixture.create_user(email="test@localhost", password=valid_password) + data_fixture.create_user(email="existing@localhost") + handler = UserHandler() + + signer = handler.get_change_email_signer() + token = signer.dumps({"user_id": user.id, "new_email": "existing@localhost"}) + + with pytest.raises(UserAlreadyExist): + handler.change_email(token) + + # Verify the user's email was not changed + user.refresh_from_db() + assert user.email == "test@localhost" diff --git a/changelog/entries/unreleased/feature/1420_allow_changing_account_email.json b/changelog/entries/unreleased/feature/1420_allow_changing_account_email.json new file mode 100644 index 0000000000..561c7a93eb --- /dev/null +++ b/changelog/entries/unreleased/feature/1420_allow_changing_account_email.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Allow changing account email address.", + "issue_origin": "github", + "issue_number": 1420, + "domain": "core", + "bullet_points": [], + "created_at": "2025-11-24" +} diff --git a/enterprise/backend/src/baserow_enterprise/api/role/serializers.py b/enterprise/backend/src/baserow_enterprise/api/role/serializers.py index 3886034266..56d6af146f 100644 --- a/enterprise/backend/src/baserow_enterprise/api/role/serializers.py +++ b/enterprise/backend/src/baserow_enterprise/api/role/serializers.py @@ -70,7 +70,8 @@ def to_internal_value(self, data): @extend_schema_field(OpenApiTypes.STR) class RoleField(NaturalKeyRelatedField): - pass + def get_queryset(self): + return super().get_queryset().filter(hidden=False) class CreateRoleAssignmentSerializer(serializers.Serializer): diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index 3541f6b026..82142e2751 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -73,9 +73,11 @@ def ready(self): AssignRoleWorkspaceOperationType, ReadRoleApplicationOperationType, ReadRoleTableOperationType, + ReadRoleViewOperationType, ReadRoleWorkspaceOperationType, UpdateRoleApplicationOperationType, UpdateRoleTableOperationType, + UpdateRoleViewOperationType, ) from .teams.subjects import TeamSubjectType @@ -131,6 +133,8 @@ def ready(self): operation_type_registry.register(UpdateFieldPermissionsOperationType()) operation_type_registry.register(ReadFieldPermissionsOperationType()) operation_type_registry.register(ChatAssistantChatOperationType()) + operation_type_registry.register(ReadRoleViewOperationType()) + operation_type_registry.register(UpdateRoleViewOperationType()) from baserow.contrib.database.field_rules.registries import ( field_rules_type_registry, @@ -347,6 +351,15 @@ def ready(self): assistant_tool_registry.register(ListWorkflowsToolType()) assistant_tool_registry.register(WorkflowToolFactoryToolType()) + from baserow.contrib.database.views.registries import ( + view_ownership_type_registry, + ) + from baserow.core.feature_flags import feature_flag_is_enabled + from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType + + if feature_flag_is_enabled("view_permissions"): + view_ownership_type_registry.register(RestrictedViewOwnershipType()) + # The signals must always be imported last because they use the registries # which need to be filled first. import baserow_enterprise.assistant.tasks # noqa: F @@ -357,7 +370,7 @@ def ready(self): def sync_default_roles_after_migrate(sender, **kwargs): from baserow.core.db import LockedAtomicTransaction - from .role.default_roles import default_roles + from .role.default_roles import default_roles, hidden_roles apps = kwargs.get("apps", None) @@ -379,6 +392,7 @@ def sync_default_roles_after_migrate(sender, **kwargs): for role_name, role_operations in tqdm( default_roles.items(), desc="Syncing default roles" ): + is_hidden = role_name in hidden_roles # Create any missing role or update existing ones role = all_old_roles.get(role_name, None) if role is None: @@ -386,11 +400,17 @@ def sync_default_roles_after_migrate(sender, **kwargs): uid=role_name, name=f"role.{role_name}", default=True, + hidden=is_hidden, ) - elif not role.default or role.name != f"role.{role_name}": + elif ( + not role.default + or role.name != f"role.{role_name}" + or role.hidden != is_hidden + ): role.name = f"role.{role_name}" role.default = True - role.save(update_fields=["name", "default"]) + role.hidden = is_hidden + role.save(update_fields=["name", "default", "hidden"]) # Create any missing operations for the role new_ops = Operation.objects.bulk_create( diff --git a/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py b/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py new file mode 100644 index 0000000000..717e961745 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.13 on 2025-08-25 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("baserow_enterprise", "0055_assistantchatmessage_action_group_id_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="role", + name="hidden", + field=models.BooleanField( + db_default=False, + default=False, + help_text="Hidden roles are not visible to the user and cannot be " + "set. These are used for internal purposes.", + ), + ), + ] diff --git a/enterprise/backend/src/baserow_enterprise/role/constants.py b/enterprise/backend/src/baserow_enterprise/role/constants.py index e9e97a7b4b..2cf4b48027 100644 --- a/enterprise/backend/src/baserow_enterprise/role/constants.py +++ b/enterprise/backend/src/baserow_enterprise/role/constants.py @@ -13,6 +13,10 @@ "READ": "database.table.read_role", "UPDATE": "database.table.update_role", }, + "database_view": { + "READ": "database.table.view.read_role", + "UPDATE": "database.table.view.update_role", + }, } ADMIN_ROLE_UID = "ADMIN" @@ -21,7 +25,7 @@ COMMENTER_ROLE_UID = "COMMENTER" VIEWER_ROLE_UID = "VIEWER" NO_ACCESS_ROLE_UID = getattr(settings, "NO_ACCESS_ROLE_UID", "NO_ACCESS") -READ_ONLY_ROLE_UID = getattr(settings, "READ_ONLY_ROLE_UID", "VIEWER") +READ_ONLY_ROLE_UID = getattr(settings, "READ_ONLY_ROLE_UID", "READ_ONLY") NO_ROLE_LOW_PRIORITY_ROLE_UID = getattr( settings, "NO_ROLE_LOW_PRIORITY_UID", "NO_ROLE_LOW_PRIORITY" ) diff --git a/enterprise/backend/src/baserow_enterprise/role/default_roles.py b/enterprise/backend/src/baserow_enterprise/role/default_roles.py index 69a8d2de4f..7b32d95c31 100755 --- a/enterprise/backend/src/baserow_enterprise/role/default_roles.py +++ b/enterprise/backend/src/baserow_enterprise/role/default_roles.py @@ -277,15 +277,18 @@ EDITOR_ROLE_UID, NO_ACCESS_ROLE_UID, NO_ROLE_LOW_PRIORITY_ROLE_UID, + READ_ONLY_ROLE_UID, VIEWER_ROLE_UID, ) from baserow_enterprise.role.operations import ( AssignRoleWorkspaceOperationType, ReadRoleApplicationOperationType, ReadRoleTableOperationType, + ReadRoleViewOperationType, ReadRoleWorkspaceOperationType, UpdateRoleApplicationOperationType, UpdateRoleTableOperationType, + UpdateRoleViewOperationType, ) from baserow_enterprise.teams.operations import ( CreateTeamOperationType, @@ -306,9 +309,14 @@ EDITOR_ROLE_UID: [], COMMENTER_ROLE_UID: [], VIEWER_ROLE_UID: [], + READ_ONLY_ROLE_UID: [], NO_ACCESS_ROLE_UID: [], NO_ROLE_LOW_PRIORITY_ROLE_UID: [], } +# Virtual roles are only used in-code, and it's not possible for the user to use these. +# The READ_ONLY role is used give to the user when they don't have access to a lower +# level object scope, but have access a higher one. +hidden_roles = [READ_ONLY_ROLE_UID] if settings.BASEROW_PERSONAL_VIEW_LOWEST_ROLE_ALLOWED not in default_roles: raise ImproperlyConfigured( @@ -321,7 +329,12 @@ CreateAndUsePersonalViewOperationType ) -default_roles[VIEWER_ROLE_UID].extend( +# Note that the read only role can automatically be assigned to the user if they have a +# role assigned on a higher scope. If the user for example has `NO_ACCESS` to a +# database, but has been given `EDITOR` role to the table, then they will automatically +# get the viewer role of the database. The individual endpoints or filter queryset +# rules must prevent accidental data exposure. +default_roles[READ_ONLY_ROLE_UID].extend( default_roles[NO_ACCESS_ROLE_UID] + [ ReadWorkspaceOperationType, @@ -359,7 +372,11 @@ ListWidgetsOperationType, ListDashboardDataSourcesOperationType, ReadDashboardDataSourceOperationType, - DispatchDashboardDataSourceOperationType, + ] +) +default_roles[VIEWER_ROLE_UID].extend( + default_roles[READ_ONLY_ROLE_UID] + + [ ReadMCPEndpointOperationType, CreateMCPEndpointOperationType, UpdateMCPEndpointOperationType, @@ -367,6 +384,7 @@ ChatAssistantChatOperationType, ReadFieldRuleOperationType, ExportTableOperationType, + DispatchDashboardDataSourceOperationType, ] ) default_roles[COMMENTER_ROLE_UID].extend( @@ -569,5 +587,7 @@ DeleteApplicationSnapshotOperationType, RestoreDomainOperationType, ListWorkspaceAuditLogEntriesOperationType, + ReadRoleViewOperationType, + UpdateRoleViewOperationType, ] ) diff --git a/enterprise/backend/src/baserow_enterprise/role/handler.py b/enterprise/backend/src/baserow_enterprise/role/handler.py index ffa107b097..53c0dbfa74 100755 --- a/enterprise/backend/src/baserow_enterprise/role/handler.py +++ b/enterprise/backend/src/baserow_enterprise/role/handler.py @@ -561,11 +561,9 @@ def get_computed_roles( """ Returns the computed roles for the given actor on the given context. - :param workspace: The workspace in which we want the roles for. - :param actor: The actor for whom we want the roles for. + :param roles_per_scopes: The roles that the user has per scope object. :param context: The context on which we want to now the role. - :param include_trash: If true then also checks even if given workspace has been - trashed instead of raising a DoesNotExist exception. + :param cache: A cache dict to temporarily store reusable values in. :return: A list of roles that applies on this context. """ diff --git a/enterprise/backend/src/baserow_enterprise/role/models.py b/enterprise/backend/src/baserow_enterprise/role/models.py index 4c5927ca49..386a6cdfc9 100755 --- a/enterprise/backend/src/baserow_enterprise/role/models.py +++ b/enterprise/backend/src/baserow_enterprise/role/models.py @@ -8,13 +8,21 @@ from baserow.core.models import Operation, Workspace +class RoleQuerySet(models.QuerySet): + def get_by_natural_key(self, uid): + return self.get(uid=uid) + + class RoleManager(models.Manager): """ - A manager that adds the `.get_by_natural_key()` to `Role` class. + A manager that adds `.get_by_natural_key()` to both Manager and QuerySet. """ + def get_queryset(self): + return RoleQuerySet(self.model, using=self._db) + def get_by_natural_key(self, uid): - return self.get(uid=uid) + return self.get_queryset().get_by_natural_key(uid) class Role(CreatedAndUpdatedOnMixin): @@ -38,6 +46,12 @@ class Role(CreatedAndUpdatedOnMixin): default=False, help_text="True if this role is a default role. The default role are the roles you can use by default.", ) + hidden = models.BooleanField( + default=False, + db_default=False, + help_text="Hidden roles are not visible to the user and cannot be set. These " + "are used for internal purposes.", + ) workspace = models.ForeignKey( Workspace, diff --git a/enterprise/backend/src/baserow_enterprise/role/operations.py b/enterprise/backend/src/baserow_enterprise/role/operations.py index 6a31e11213..1955ca50fd 100644 --- a/enterprise/backend/src/baserow_enterprise/role/operations.py +++ b/enterprise/backend/src/baserow_enterprise/role/operations.py @@ -1,4 +1,5 @@ from baserow.contrib.database.table.operations import DatabaseTableOperationType +from baserow.contrib.database.views.operations import ViewOperationType from baserow.core.operations import ApplicationOperationType, WorkspaceCoreOperationType @@ -24,3 +25,11 @@ class ReadRoleTableOperationType(DatabaseTableOperationType): class UpdateRoleTableOperationType(DatabaseTableOperationType): type = "database.table.update_role" + + +class ReadRoleViewOperationType(ViewOperationType): + type = "database.table.view.read_role" + + +class UpdateRoleViewOperationType(ViewOperationType): + type = "database.table.view.update_role" diff --git a/enterprise/backend/src/baserow_enterprise/role/permission_manager.py b/enterprise/backend/src/baserow_enterprise/role/permission_manager.py index 4093cd67b9..e53da74cce 100644 --- a/enterprise/backend/src/baserow_enterprise/role/permission_manager.py +++ b/enterprise/backend/src/baserow_enterprise/role/permission_manager.py @@ -22,6 +22,7 @@ from baserow_enterprise.features import RBAC from baserow_enterprise.role.handler import RoleAssignmentHandler +from .constants import READ_ONLY_ROLE_UID from .models import Role User = get_user_model() @@ -67,7 +68,9 @@ def read_operations(self) -> Set[str]: return set( op.name - for op in RoleAssignmentHandler().get_role_by_uid("VIEWER").operations.all() + for op in RoleAssignmentHandler() + .get_role_by_uid(READ_ONLY_ROLE_UID) + .operations.all() ) def check_multiple_permissions( diff --git a/enterprise/backend/src/baserow_enterprise/structure_types.py b/enterprise/backend/src/baserow_enterprise/structure_types.py index ab4fd6897f..73ed8433cc 100644 --- a/enterprise/backend/src/baserow_enterprise/structure_types.py +++ b/enterprise/backend/src/baserow_enterprise/structure_types.py @@ -4,6 +4,7 @@ from baserow_premium.license.handler import LicenseHandler +from baserow.contrib.database.views.models import View from baserow.core.models import Application from baserow.core.registries import ImportExportConfig, SerializationProcessorType from baserow.core.types import SerializationProcessorScope @@ -55,6 +56,11 @@ def import_serialized( if isinstance(scope, Application): scope = getattr(scope, "application_ptr", scope) + # View subclass scopes can't be passed to the role assignment handler because + # the permissions are stored on the `View` level. + if isinstance(scope, View): + scope = getattr(scope, "view_ptr", scope) + serialized_role_assignments = serialized_scope.get("role_assignments", []) with atomic_if_not_already(): @@ -99,6 +105,11 @@ def export_serialized( if isinstance(scope, Application): scope = getattr(scope, "application_ptr", scope) + # View subclass scopes can't be passed to the role assignment handler because + # the permissions are stored on the `View` level. + if isinstance(scope, View): + scope = getattr(scope, "view_ptr", scope) + serialized_role_assignments = [] role_assignments = RoleAssignmentHandler().get_role_assignments( workspace, scope diff --git a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py new file mode 100644 index 0000000000..577910ca58 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py @@ -0,0 +1,28 @@ +from django.contrib.auth.models import AbstractUser + +from baserow_premium.license.handler import LicenseHandler + +from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.registries import ViewOwnershipType +from baserow.core.exceptions import PermissionDenied +from baserow.core.models import Workspace +from baserow_enterprise.features import RBAC + + +class RestrictedViewOwnershipType(ViewOwnershipType): + """ + Represents view that are shared between all users, but users without the + permissions to create/update/delete filters will not be able to see the rows not + matching the filters. This is used to give some users only access to part of the + rows in a table. + """ + + type = "restricted" + + def change_ownership_type(self, user: AbstractUser, view: View) -> View: + # It's not possible to change to and from restricted view type because that + # could accidentally expose or restrict data. + raise PermissionDenied() + + def view_created(self, user: AbstractUser, view: "View", workspace: Workspace): + LicenseHandler.raise_if_user_doesnt_have_feature(RBAC, user, workspace) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/role/test_role_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/role/test_role_views.py index 03129f6cd8..1c8acd1171 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/api/role/test_role_views.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/role/test_role_views.py @@ -10,6 +10,7 @@ ) from baserow.core.subjects import UserSubjectType +from baserow.test_utils.helpers import AnyInt from baserow_enterprise.role.handler import RoleAssignmentHandler from baserow_enterprise.role.models import Role, RoleAssignment @@ -554,6 +555,58 @@ def test_batch_assign_role(data_fixture, api_client): ] +@pytest.mark.django_db +def test_batch_assign_role_to_database_view(data_fixture, api_client): + user, token = data_fixture.create_user_and_token() + user2 = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user, members=[user2]) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + view = data_fixture.create_grid_view(table=table) + + editor_role = Role.objects.get(uid="EDITOR") + + assert len(RoleAssignment.objects.all()) == 0 + + url = reverse("api:enterprise:role:batch", kwargs={"workspace_id": workspace.id}) + + response = api_client.post( + url, + { + "items": [ + { + "scope_id": view.id, + "scope_type": "database_view", + "subject_id": user2.id, + "subject_type": UserSubjectType.type, + "role": editor_role.uid, + }, + ] + }, + format="json", + **{"HTTP_AUTHORIZATION": f"JWT {token}"}, + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert response_json == [ + { + "id": AnyInt(), + "role": editor_role.uid, + "scope_id": view.id, + "scope_type": "database_view", + "subject_id": user2.id, + "subject_type": UserSubjectType.type, + "subject": { + "email": user2.email, + "first_name": user2.first_name, + "id": user2.id, + "username": user2.username, + }, + }, + ] + + @pytest.mark.django_db def test_batch_assign_role_duplicates(data_fixture, api_client): user, token = data_fixture.create_user_and_token() diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py new file mode 100644 index 0000000000..421f587270 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py @@ -0,0 +1,138 @@ +from django.test.utils import override_settings +from django.urls import reverse + +import pytest +from rest_framework.status import ( + HTTP_200_OK, + HTTP_401_UNAUTHORIZED, + HTTP_402_PAYMENT_REQUIRED, +) + +from baserow.core.subjects import UserSubjectType +from baserow_enterprise.role.models import Role + + +@pytest.mark.django_db +def test_create_restricted_grid_view_without_license( + api_client, enterprise_data_fixture +): + user, token = enterprise_data_fixture.create_user_and_token() + table = enterprise_data_fixture.create_database_table(user=user) + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "ownership_type": "restricted", + "filter_type": "OR", + "filters_disabled": True, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_402_PAYMENT_REQUIRED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_restricted_grid_view_with_license(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + table = enterprise_data_fixture.create_database_table(user=user) + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "ownership_type": "restricted", + "filter_type": "OR", + "filters_disabled": True, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()["ownership_type"] == "restricted" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_create_view_if_user_has_only_permissions_to_view( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[]) + enterprise_data_fixture.create_user_workspace( + workspace=workspace, user=user2, permissions="NO_ACCESS", order=0 + ) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(user=user, database=database) + view = enterprise_data_fixture.create_grid_view(table=table) + + editor_role = Role.objects.get(uid="EDITOR") + + response = api_client.post( + reverse("api:enterprise:role:batch", kwargs={"workspace_id": workspace.id}), + { + "items": [ + { + "scope_id": view.id, + "scope_type": "database_view", + "subject_id": user2.id, + "subject_type": UserSubjectType.type, + "role": editor_role.uid, + }, + ] + }, + format="json", + **{"HTTP_AUTHORIZATION": f"JWT {token}"}, + ) + assert response.status_code == HTTP_200_OK + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "filter_type": "OR", + "filters_disabled": True, + "ownership_type": "collaborative", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "filter_type": "OR", + "filters_disabled": True, + "ownership_type": "personal", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "filter_type": "OR", + "filters_disabled": True, + "ownership_type": "restricted", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED diff --git a/enterprise/backend/tests/baserow_enterprise_tests/enterprise/test_enterprise_license.py b/enterprise/backend/tests/baserow_enterprise_tests/enterprise/test_enterprise_license.py index 4c330a93ba..25b7a3500f 100755 --- a/enterprise/backend/tests/baserow_enterprise_tests/enterprise/test_enterprise_license.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/enterprise/test_enterprise_license.py @@ -338,6 +338,7 @@ def test_enterprise_license_counts_viewers_as_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 2, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -384,6 +385,7 @@ def test_user_who_is_editor_in_one_workspace_and_viewer_in_another_is_not_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -429,6 +431,7 @@ def test_user_marked_for_deletion_is_not_counted_as_a_paid_user( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -474,6 +477,7 @@ def test_user_deactivated_user_is_not_counted_as_a_paid_user( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -610,6 +614,7 @@ def test_user_with_paid_table_role_is_not_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -647,6 +652,7 @@ def test_user_with_free_table_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -684,6 +690,7 @@ def test_user_with_paid_database_role_is_not_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -721,6 +728,7 @@ def test_user_with_free_database_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -761,6 +769,7 @@ def test_user_with_paid_table_role_is_not_free_from_team( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -801,6 +810,7 @@ def test_user_with_free_table_role_is_free_from_team( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -841,6 +851,7 @@ def test_user_with_paid_database_role_is_not_free_from_team( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -881,6 +892,7 @@ def test_user_with_free_database_role_is_free_from_team( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -923,6 +935,7 @@ def test_user_in_deleted_team_with_paid_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -960,6 +973,7 @@ def test_inactive_user_with_paid_role_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -980,6 +994,7 @@ def test_inactive_user_with_paid_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1020,6 +1035,7 @@ def test_inactive_user_in_team_with_paid_role_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1040,6 +1056,7 @@ def test_inactive_user_in_team_with_paid_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1077,6 +1094,7 @@ def test_user_to_be_deleted_with_paid_role_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1097,6 +1115,7 @@ def test_user_to_be_deleted_with_paid_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1137,6 +1156,7 @@ def test_user_to_be_deleted_in_team_with_paid_role_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1157,6 +1177,7 @@ def test_user_to_be_deleted_in_team_with_paid_role_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1248,6 +1269,7 @@ def test_complex_free_vs_paid_scenario( "EDITOR": 2, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1289,6 +1311,7 @@ def test_user_with_role_paid_on_trashed_database_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1310,6 +1333,7 @@ def test_user_with_role_paid_on_trashed_database_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1347,6 +1371,7 @@ def test_user_with_role_paid_on_database_in_trashed_workspace_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1368,6 +1393,7 @@ def test_user_with_role_paid_on_database_in_trashed_workspace_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1405,6 +1431,7 @@ def test_user_with_role_paid_on_trashed_table_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1426,6 +1453,7 @@ def test_user_with_role_paid_on_trashed_table_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1466,6 +1494,7 @@ def test_user_in_team_with_role_paid_on_trashed_database_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1487,6 +1516,7 @@ def test_user_in_team_with_role_paid_on_trashed_database_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1527,6 +1557,7 @@ def test_user_in_team_with_role_paid_on_trashed_table_is_free( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1548,6 +1579,7 @@ def test_user_in_team_with_role_paid_on_trashed_table_is_free( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1596,6 +1628,7 @@ def test_user_summary_calculation_for_enterprise_doesnt_do_n_plus_one_queries( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1637,6 +1670,7 @@ def test_user_summary_calculation_for_enterprise_doesnt_do_n_plus_one_queries( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1728,6 +1762,7 @@ def test_can_query_for_summary_per_workspace( "EDITOR": 2, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1748,6 +1783,7 @@ def test_can_query_for_summary_per_workspace( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1767,6 +1803,7 @@ def test_can_query_for_summary_per_workspace( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1813,6 +1850,7 @@ def test_user_with_team_and_user_role_picks_highest_of_either( "EDITOR": 1, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1840,6 +1878,7 @@ def test_order_of_roles_is_as_expected( expected_role_order = [ "NO_ROLE_LOW_PRIORITY", "NO_ACCESS", + "READ_ONLY", "VIEWER", "COMMENTER", "EDITOR", @@ -1888,6 +1927,7 @@ def test_weird_workspace_user_permission_doesnt_break_usage_check( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, "WEIRD_VALUE": 1, @@ -1948,6 +1988,7 @@ def test_weird_ras_for_wrong_workspace_not_counted_when_querying_for_single_work "EDITOR": 0, "COMMENTER": 0, "VIEWER": 1, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -1983,6 +2024,7 @@ def test_missing_roles_doesnt_cause_crash_and_members_admins_are_treated_as_non_ "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 0, "NO_ROLE_LOW_PRIORITY": 0, }, @@ -2073,6 +2115,7 @@ def test_orphaned_paid_role_assignments_dont_get_counted( "EDITOR": 0, "COMMENTER": 0, "VIEWER": 0, + "READ_ONLY": 0, "NO_ACCESS": 1, "NO_ROLE_LOW_PRIORITY": 0, }, diff --git a/enterprise/backend/tests/baserow_enterprise_tests/role/test_view_permissions.py b/enterprise/backend/tests/baserow_enterprise_tests/role/test_view_permissions.py index 80f0dce80f..240f2efad7 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/role/test_view_permissions.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/role/test_view_permissions.py @@ -1,8 +1,10 @@ +from django.contrib.contenttypes.models import ContentType from django.test import override_settings import pytest from baserow.contrib.database.views.handler import ViewHandler +from baserow.contrib.database.views.models import View from baserow.core.exceptions import PermissionDenied from baserow_enterprise.role.handler import RoleAssignmentHandler @@ -66,3 +68,44 @@ def test_update_form_view_and_notification_as_editor_fails(enterprise_data_fixtu handler.update_view( user=user, view=form, receive_notification_on_submit=True, name="Test" ) + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_duplicate_view_and_remember_role(enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + user, token = enterprise_data_fixture.create_user_and_token( + email="test@test.nl", password="password", first_name="Test1" + ) + user2 = enterprise_data_fixture.create_user( + email="test2@test.nl", password="password", first_name="Test1" + ) + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + grid = enterprise_data_fixture.create_grid_view(table=table) + view = View.objects.get(pk=grid.id) + admin_role = RoleAssignmentHandler().get_role_by_uid("ADMIN") + editor_role = RoleAssignmentHandler().get_role_by_uid("EDITOR") + RoleAssignmentHandler().assign_role( + user, workspace, role=admin_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, workspace, role=editor_role, scope=workspace + ) + RoleAssignmentHandler().assign_role(user2, workspace, role=admin_role, scope=view) + + handler = ViewHandler() + duplicated_grid = handler.duplicate_view(user, grid) + duplicated_view = View.objects.get(pk=duplicated_grid.id) + + role_assignments = RoleAssignmentHandler().get_role_assignments( + workspace, duplicated_view + ) + assert len(role_assignments) == 1 + assert role_assignments[0].workspace_id == workspace.id + assert role_assignments[0].subject_id == user2.id + assert role_assignments[0].scope_id == duplicated_view.id + assert ( + role_assignments[0].scope_type_id == ContentType.objects.get_for_model(View).id + ) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesModal.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesModal.vue index 240aa2e865..8339f00aa0 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesModal.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesModal.vue @@ -34,6 +34,22 @@ @role-updated="updateRole(tableRoleAssignments, ...arguments)" /> + + + @@ -61,11 +77,17 @@ export default { required: false, default: null, }, + view: { + type: Object, + required: false, + default: null, + }, }, data() { return { databaseRoleAssignments: [], tableRoleAssignments: [], + viewRoleAssignments: [], selectedTabIndex: 0, teams: [], loading: false, @@ -92,11 +114,23 @@ export default { ) ) }, + canManageView() { + return ( + this.view && + this.$hasPermission( + 'database.table.view.read_role', + this.view, + this.workspace.id + ) + ) + }, }, methods: { async onShow() { - if (this.table && this.canManageTable) { - this.selectedTabIndex = this.canManageDatabase ? 1 : 0 + if (this.view) { + this.selectedTabIndex = 2 + } else if (this.table) { + this.selectedTabIndex = 1 } this.loading = true @@ -128,9 +162,17 @@ export default { ) this.tableRoleAssignments = tableRoleAssignments } + + if (this.canManageView) { + const { data: viewRoleAssignments } = await RoleAssignmentsService( + this.$client + ).getRoleAssignments(this.workspace.id, this.view.id, 'database_view') + this.viewRoleAssignments = viewRoleAssignments + } } catch (error) { this.databaseRoleAssignments = [] this.tableRoleAssignments = [] + this.viewRoleAssignments = [] this.showError( this.$t('memberRolesModal.error.title'), this.$t('memberRolesModal.error.description') @@ -195,6 +237,28 @@ export default { this.tableRoleAssignments = this.tableRoleAssignments.concat(roleAssignments) }, + async inviteViewMembers(members, role) { + const roleAssignments = await this.invite( + members, + 'auth.User', + role, + 'database_view', + this.view.id + ) + this.viewRoleAssignments = + this.viewRoleAssignments.concat(roleAssignments) + }, + async inviteViewTeams(teams, role) { + const roleAssignments = await this.invite( + teams, + 'baserow_enterprise.Team', + role, + 'database_view', + this.view.id + ) + this.viewRoleAssignments = + this.viewRoleAssignments.concat(roleAssignments) + }, async invite(subjects, subjectType, role, scopeType, scopeId) { this.loading = true diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesTab.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesTab.vue index 24c8e1fb6b..59b064f5b9 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesTab.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesTab.vue @@ -109,6 +109,8 @@ export default { return 'database' case 'database_table': return 'table' + case 'database_view': + return 'view' default: return 'database' } diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesViewContextItem.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesViewContextItem.vue new file mode 100644 index 0000000000..d604e6aad0 --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/member-roles/MemberRolesViewContextItem.vue @@ -0,0 +1,66 @@ + + + diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index 88aa9144b7..8d5964b17f 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -194,7 +194,8 @@ }, "memberRolesModal": { "memberRolesDatabaseTabTitle": "Database", - "memberRolesTableTabTitle": "Current Table", + "memberRolesTableTabTitle": "Table", + "memberRolesViewTabTitle": "View", "error": { "title": "Could not fetch data", "description": "An error occurred while fetching the member data." @@ -218,6 +219,15 @@ "warningTitle": "Other users might inherit access!", "warningMessage": "Other users might inherit access via their respective roles on the parent database or workspace.", "headerTooltip": "Table roles will override workspace and database roles." + }, + "view": { + "title": "{name} view", + "selectMembers": "Select members", + "everyoneHasAccess": "Everyone at {name} workspace can access this view.", + "onlyYouHaveAccess": "Only you can access this view.", + "warningTitle": "Other users might inherit access!", + "warningMessage": "Other users might inherit access via their respective roles on the parent table, database or workspace.", + "headerTooltip": "View roles will override workspace, database, and table roles." } }, "memberRolesShareToggle": { @@ -694,5 +704,9 @@ "invalidDurationEmpty": "Duration is empty", "invalidDurationValue": "Duration value is not valid", "invalidDurationMismatch": "Duration value mismatch" + }, + "viewOwnershipType": { + "restricted": "Restricted", + "restrictedDescription": "Editors and lower can only see the filtered data and visible fields. It’s possible to manage the members." } } diff --git a/enterprise/web-frontend/modules/baserow_enterprise/plugin.js b/enterprise/web-frontend/modules/baserow_enterprise/plugin.js index c85a198af6..16d2f5abd2 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/plugin.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/plugin.js @@ -88,6 +88,7 @@ import { } from '@baserow_enterprise/dateDependencyTypes' import { CustomCodeBuilderSettingType } from '@baserow_enterprise/builderSettingTypes' import { RealtimePushTwoWaySyncStrategyType } from '@baserow_enterprise/twoWaySyncStrategyTypes' +import { RestrictedViewOwnershipType } from '@baserow_enterprise/viewOwnershipTypes' export default (context) => { const { app, isDev, store } = context @@ -269,4 +270,11 @@ export default (context) => { 'twoWaySyncStrategy', new RealtimePushTwoWaySyncStrategyType(context) ) + + if (app.$featureFlagIsEnabled('view_permissions')) { + app.$registry.register( + 'viewOwnershipType', + new RestrictedViewOwnershipType(context) + ) + } } diff --git a/enterprise/web-frontend/modules/baserow_enterprise/plugins.js b/enterprise/web-frontend/modules/baserow_enterprise/plugins.js index 6a7cc65440..cc901808c3 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/plugins.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/plugins.js @@ -3,17 +3,19 @@ import ChatwootSupportSidebarWorkspace from '@baserow_enterprise/components/Chat import AuditLogSidebarWorkspace from '@baserow_enterprise/components/AuditLogSidebarWorkspace' import MemberRolesDatabaseContextItem from '@baserow_enterprise/components/member-roles/MemberRolesDatabaseContextItem' import MemberRolesTableContextItem from '@baserow_enterprise/components/member-roles/MemberRolesTableContextItem' +import MemberRolesViewContextItem from '@baserow_enterprise/components/member-roles/MemberRolesViewContextItem' import EnterpriseFeatures from '@baserow_enterprise/features' import SnapshotModalWarning from '@baserow_enterprise/components/SnapshotModalWarning' import EnterpriseSettings from '@baserow_enterprise/components/EnterpriseSettings' import EnterpriseSettingsOverrideDashboardHelp from '@baserow_enterprise/components/EnterpriseSettingsOverrideDashboardHelp' import EnterpriseLogo from '@baserow_enterprise/components/EnterpriseLogo' import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes' -import ExportWorkspaceModalWarning from '@baserow_enterprise/components/ExportWorkspaceModalWarning' import AssistantSidebarItem from '@baserow_enterprise/components/assistant/AssistantSidebarItem' import AssistantPanel from '@baserow_enterprise/components/assistant/AssistantPanel' import DateDependencyMenuItem from '@baserow_enterprise/components/dateDependency/DateDependencyMenuItem' import DateDependencyFieldTypeIcon from '@baserow_enterprise/components/dateDependency/DateDependencyFieldTypeIcon' +import ExportWorkspaceModalWarning from '@baserow_enterprise/components/ExportWorkspaceModalWarning' +import { RestrictedViewOwnershipType } from '@baserow_enterprise/viewOwnershipTypes' export class EnterprisePlugin extends BaserowPlugin { static getType() { @@ -63,16 +65,30 @@ export class EnterprisePlugin extends BaserowPlugin { return out } - getAdditionalViewContextComponents(workspace, view) { - return [DateDependencyMenuItem] - } - getGridViewFieldTypeIconsBefore(workspace, view, field) { const out = [] out.push(DateDependencyFieldTypeIcon) return out } + getAdditionalViewContextComponents(workspace, table, view) { + const components = [] + if ( + this.app.$hasPermission( + 'database.table.view.read_role', + view, + workspace.id + ) && + // Assigning a role to a user on view level only actually does something for + // the restricted view. So we're only showing the modal there. + view.ownership_type === RestrictedViewOwnershipType.getType() + ) { + components.push(MemberRolesViewContextItem) + } + components.push(DateDependencyMenuItem) + return components + } + getExtraSnapshotModalComponents(workspace) { const rbacSupport = this.app.$hasFeature( EnterpriseFeatures.RBAC, diff --git a/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js b/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js new file mode 100644 index 0000000000..f73406b865 --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js @@ -0,0 +1,48 @@ +import { ViewOwnershipType } from '@baserow/modules/database/viewOwnershipTypes' +import EnterpriseFeatures from '@baserow_enterprise/features' +import PaidFeaturesModal from '@baserow_premium/components/PaidFeaturesModal' +import { RBACPaidFeature } from '@baserow_enterprise/paidFeatures' + +export class RestrictedViewOwnershipType extends ViewOwnershipType { + static getType() { + return 'restricted' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewOwnershipType.restricted') + } + + getDescription() { + const { i18n } = this.app + return i18n.t('viewOwnershipType.restrictedDescription') + } + + getFeatureName() { + const { i18n } = this.app + return i18n.t('enterpriseFeatures.restrictedViews') + } + + getIconClass() { + return 'iconoir-shield-check' + } + + isDeactivated(workspaceId) { + return !this.app.$hasFeature(EnterpriseFeatures.RBAC, workspaceId) + } + + getDeactivatedText() { + return this.app.i18n.t('enterprise.deactivated') + } + + getDeactivatedModal() { + return [ + PaidFeaturesModal, + { 'initial-selected-type': RBACPaidFeature.getType() }, + ] + } + + getListViewTypeSort() { + return 30 + } +} diff --git a/premium/backend/src/baserow_premium/views/signals.py b/premium/backend/src/baserow_premium/views/signals.py index baa6e6d426..f7f4d9f7c3 100644 --- a/premium/backend/src/baserow_premium/views/signals.py +++ b/premium/backend/src/baserow_premium/views/signals.py @@ -1,56 +1,11 @@ from django.contrib.auth import get_user_model -from django.contrib.auth.models import AbstractUser from django.db.models.signals import pre_delete -from django.dispatch import receiver - -from baserow_premium.license.features import PREMIUM -from baserow_premium.license.handler import LicenseHandler -from baserow_premium.views.models import OWNERSHIP_TYPE_PERSONAL - -from baserow.contrib.database.views import signals as view_signals -from baserow.contrib.database.views.models import OWNERSHIP_TYPE_COLLABORATIVE -from baserow.core.exceptions import PermissionDenied -from baserow.core.models import Workspace from .handler import delete_personal_views User = get_user_model() -def premium_check_ownership_type( - user: AbstractUser, workspace: Workspace, ownership_type: str -) -> None: - """ - Checks whether the provided ownership type is supported for the user. - - Should be replaced with a support for creating views - in the ViewOwnershipPermissionManagerType once it is possible. - - :param user: The user on whose behalf the operation is performed. - :param workspace: The workspace for which the check is performed. - :param ownership_type: View's ownership type. - :raises PermissionDenied: When not allowed. - """ - - premium = LicenseHandler.user_has_feature(PREMIUM, user, workspace) - - if premium: - if ownership_type not in [ - OWNERSHIP_TYPE_COLLABORATIVE, - OWNERSHIP_TYPE_PERSONAL, - ]: - raise PermissionDenied() - else: - if ownership_type != OWNERSHIP_TYPE_COLLABORATIVE: - raise PermissionDenied() - - -@receiver(view_signals.view_created) -def view_created(sender, view, user, **kwargs): - workspace = view.table.database.workspace - premium_check_ownership_type(user, workspace, view.ownership_type) - - def before_user_permanently_deleted(sender, instance, **kwargs): delete_personal_views(instance.id) @@ -60,6 +15,5 @@ def connect_to_user_pre_delete_signal(): __all__ = [ - "view_created", "connect_to_user_pre_delete_signal", ] diff --git a/premium/backend/src/baserow_premium/views/view_ownership_types.py b/premium/backend/src/baserow_premium/views/view_ownership_types.py index 88c7fc1ba7..a48c046d4a 100644 --- a/premium/backend/src/baserow_premium/views/view_ownership_types.py +++ b/premium/backend/src/baserow_premium/views/view_ownership_types.py @@ -15,6 +15,7 @@ from baserow.contrib.database.views.registries import ViewOwnershipType from baserow.core.exceptions import PermissionDenied from baserow.core.handler import CoreHandler +from baserow.core.models import Workspace class PersonalViewOwnershipType(ViewOwnershipType): @@ -97,3 +98,6 @@ def change_ownership_type(self, user: AbstractUser, view: View) -> View: view.ownership_type = self.type view.owned_by = user return view + + def view_created(self, user: AbstractUser, view: "View", workspace: Workspace): + LicenseHandler.raise_if_user_doesnt_have_feature(PREMIUM, user, workspace) diff --git a/premium/backend/src/baserow_premium/views/view_types.py b/premium/backend/src/baserow_premium/views/view_types.py index 3884863019..75d9026f57 100644 --- a/premium/backend/src/baserow_premium/views/view_types.py +++ b/premium/backend/src/baserow_premium/views/view_types.py @@ -42,6 +42,7 @@ from baserow.contrib.database.table.models import Table from baserow.contrib.database.views.models import View from baserow.contrib.database.views.registries import ViewType +from baserow.core.registries import ImportExportConfig from .exceptions import ( KanbanViewFieldDoesNotBelongToSameTable, @@ -137,6 +138,7 @@ def prepare_values(self, values, table, user): def export_serialized( self, kanban: View, + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -145,7 +147,9 @@ def export_serialized( Adds the serialized kanban view options to the exported dict. """ - serialized = super().export_serialized(kanban, cache, files_zip, storage) + serialized = super().export_serialized( + kanban, import_export_config, cache, files_zip, storage + ) if kanban.single_select_field_id: serialized["single_select_field_id"] = kanban.single_select_field_id @@ -170,6 +174,7 @@ def import_serialized( self, table: Table, serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -191,7 +196,7 @@ def import_serialized( field_options = serialized_copy.pop("field_options") kanban_view = super().import_serialized( - table, serialized_copy, id_mapping, files_zip, storage + table, serialized_copy, import_export_config, id_mapping, files_zip, storage ) if kanban_view is not None: @@ -390,6 +395,7 @@ def prepare_values(self, values, table, user): def export_serialized( self, calendar: View, + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -398,7 +404,9 @@ def export_serialized( Adds the serialized calendar view options to the exported dict. """ - serialized = super().export_serialized(calendar, cache, files_zip, storage) + serialized = super().export_serialized( + calendar, import_export_config, cache, files_zip, storage + ) if calendar.date_field_id: serialized["date_field_id"] = calendar.date_field_id @@ -420,6 +428,7 @@ def import_serialized( self, table: Table, serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -437,7 +446,7 @@ def import_serialized( field_options = serialized_copy.pop("field_options") calendar_view = super().import_serialized( - table, serialized_copy, id_mapping, files_zip, storage + table, serialized_copy, import_export_config, id_mapping, files_zip, storage ) if calendar_view is not None: @@ -706,6 +715,7 @@ def prepare_values(self, values, table, user): def export_serialized( self, timeline: View, + import_export_config: ImportExportConfig, cache: Optional[Dict] = None, files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -714,7 +724,9 @@ def export_serialized( Adds the serialized timeline view options to the exported dict. """ - serialized = super().export_serialized(timeline, cache, files_zip, storage) + serialized = super().export_serialized( + timeline, import_export_config, cache, files_zip, storage + ) if timeline.start_date_field_id: serialized["start_date_field_id"] = timeline.start_date_field_id if timeline.end_date_field_id: @@ -738,6 +750,7 @@ def import_serialized( self, table: Table, serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, id_mapping: Dict[str, Any], files_zip: Optional[ZipFile] = None, storage: Optional[Storage] = None, @@ -758,7 +771,7 @@ def import_serialized( field_options = serialized_copy.pop("field_options") timeline_view = super().import_serialized( - table, serialized_copy, id_mapping, files_zip, storage + table, serialized_copy, import_export_config, id_mapping, files_zip, storage ) if "database_timeline_view_field_options" not in id_mapping: diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_views.py index 4e14954786..35258b7dc8 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_views.py @@ -7,7 +7,7 @@ KanbanViewFieldOptions, TimelineViewFieldOptions, ) -from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_200_OK, HTTP_402_PAYMENT_REQUIRED from baserow.contrib.database.rows.handler import RowHandler @@ -250,3 +250,47 @@ def test_can_limit_linked_items_in_premium_public_views( resp = api_client.get(f"{timeline_url}?limit_linked_items=2", format="json") assert resp.status_code == HTTP_200_OK assert len(resp.json()["results"][0][link_a_to_b.db_column]) == 2 + + +@pytest.mark.django_db +def test_create_personal_grid_view_without_license(api_client, premium_data_fixture): + user, token = premium_data_fixture.create_user_and_token() + table = premium_data_fixture.create_database_table(user=user) + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "ownership_type": "personal", + "filter_type": "OR", + "filters_disabled": True, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_402_PAYMENT_REQUIRED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_personal_grid_view_with_license(api_client, premium_data_fixture): + user, token = premium_data_fixture.create_user_and_token( + has_active_premium_license=True + ) + table = premium_data_fixture.create_database_table(user=user) + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test 1", + "type": "grid", + "ownership_type": "personal", + "filter_type": "OR", + "filters_disabled": True, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()["ownership_type"] == "personal" diff --git a/premium/backend/tests/baserow_premium_tests/views/test_calendar_view_type.py b/premium/backend/tests/baserow_premium_tests/views/test_calendar_view_type.py index 602f7501f9..c6b2bb9feb 100644 --- a/premium/backend/tests/baserow_premium_tests/views/test_calendar_view_type.py +++ b/premium/backend/tests/baserow_premium_tests/views/test_calendar_view_type.py @@ -31,6 +31,7 @@ from baserow.contrib.database.views.registries import view_type_registry from baserow.core.action.handler import ActionHandler from baserow.core.action.registries import action_type_registry +from baserow.core.registries import ImportExportConfig from baserow.test_utils.helpers import ( assert_undo_redo_actions_are_valid, setup_interesting_test_table, @@ -122,7 +123,10 @@ def test_calendar_view_import_export(premium_data_fixture, tmpdir): files_buffer = BytesIO() with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: serialized = calendar_view_type.export_serialized( - calendar_view, files_zip=files_zip, storage=storage + calendar_view, + ImportExportConfig(include_permission_data=False), + files_zip=files_zip, + storage=storage, ) assert serialized["id"] == calendar_view.id @@ -146,7 +150,12 @@ def test_calendar_view_import_export(premium_data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_calendar_view = calendar_view_type.import_serialized( - calendar_view.table, serialized, id_mapping, files_zip, storage + calendar_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + files_zip, + storage, ) assert calendar_view.id != imported_calendar_view.id diff --git a/premium/backend/tests/baserow_premium_tests/views/test_kanban_view_type.py b/premium/backend/tests/baserow_premium_tests/views/test_kanban_view_type.py index 2a67c1854c..eb830eadd2 100644 --- a/premium/backend/tests/baserow_premium_tests/views/test_kanban_view_type.py +++ b/premium/backend/tests/baserow_premium_tests/views/test_kanban_view_type.py @@ -13,6 +13,7 @@ from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.registries import view_type_registry +from baserow.core.registries import ImportExportConfig @pytest.mark.django_db @@ -90,7 +91,10 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: serialized = kanban_field_type.export_serialized( - kanban_view, files_zip=files_zip, storage=storage + kanban_view, + ImportExportConfig(include_permission_data=False), + files_zip=files_zip, + storage=storage, ) assert serialized["id"] == kanban_view.id @@ -119,7 +123,12 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_kanban_view = kanban_field_type.import_serialized( - kanban_view.table, serialized, id_mapping, files_zip, storage + kanban_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + files_zip, + storage, ) assert kanban_view.id != imported_kanban_view.id diff --git a/premium/backend/tests/baserow_premium_tests/views/test_premium_view_decorator_value_provider_types.py b/premium/backend/tests/baserow_premium_tests/views/test_premium_view_decorator_value_provider_types.py index f05e7fe771..d734e13592 100644 --- a/premium/backend/tests/baserow_premium_tests/views/test_premium_view_decorator_value_provider_types.py +++ b/premium/backend/tests/baserow_premium_tests/views/test_premium_view_decorator_value_provider_types.py @@ -12,6 +12,7 @@ from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.models import ViewDecoration from baserow.contrib.database.views.registries import view_type_registry +from baserow.core.registries import ImportExportConfig from baserow.test_utils.helpers import AnyStr @@ -115,9 +116,16 @@ def test_import_export_grid_view_w_decorator(data_fixture): } grid_view_type = view_type_registry.get("grid") - serialized = grid_view_type.export_serialized(grid_view, None, None, None) + serialized = grid_view_type.export_serialized( + grid_view, ImportExportConfig(include_permission_data=False), None, None, None + ) imported_grid_view = grid_view_type.import_serialized( - grid_view.table, serialized, id_mapping, None, None + grid_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + None, + None, ) imported_view_decorations = imported_grid_view.viewdecoration_set.all() diff --git a/premium/backend/tests/baserow_premium_tests/views/test_timeline_view_type.py b/premium/backend/tests/baserow_premium_tests/views/test_timeline_view_type.py index bc13ef0af2..bb6febe5fb 100644 --- a/premium/backend/tests/baserow_premium_tests/views/test_timeline_view_type.py +++ b/premium/backend/tests/baserow_premium_tests/views/test_timeline_view_type.py @@ -19,6 +19,7 @@ from baserow.contrib.database.views.registries import view_type_registry from baserow.core.action.handler import ActionHandler from baserow.core.action.registries import action_type_registry +from baserow.core.registries import ImportExportConfig from baserow.test_utils.helpers import ( assert_undo_redo_actions_are_valid, setup_interesting_test_table, @@ -114,7 +115,10 @@ def test_timeline_view_import_export(premium_data_fixture, tmpdir): files_buffer = BytesIO() with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: serialized = timeline_view_type.export_serialized( - timeline_view, files_zip=files_zip, storage=storage + timeline_view, + ImportExportConfig(include_permission_data=False), + files_zip=files_zip, + storage=storage, ) assert serialized["id"] == timeline_view.id @@ -145,7 +149,12 @@ def test_timeline_view_import_export(premium_data_fixture, tmpdir): with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_timeline_view = timeline_view_type.import_serialized( - timeline_view.table, serialized, id_mapping, files_zip, storage + timeline_view.table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping, + files_zip, + storage, ) assert timeline_view.id != imported_timeline_view.id diff --git a/premium/backend/tests/baserow_premium_tests/views/test_view_ownership_type_permission_manager.py b/premium/backend/tests/baserow_premium_tests/views/test_view_ownership_type_permission_manager.py index 2a20c104ff..18ee621815 100644 --- a/premium/backend/tests/baserow_premium_tests/views/test_view_ownership_type_permission_manager.py +++ b/premium/backend/tests/baserow_premium_tests/views/test_view_ownership_type_permission_manager.py @@ -2,6 +2,10 @@ from baserow_premium.permission_manager import ViewOwnershipPermissionManagerType from baserow.core.registries import object_scope_type_registry, operation_type_registry +from baserow_enterprise.role.operations import ( + ReadRoleViewOperationType, + UpdateRoleViewOperationType, +) @pytest.mark.view_ownership @@ -29,9 +33,13 @@ def test_all_operations_allowed_for_personal_views_have_been_checked_by_a_dev(): "if this new operation should be allowed for any viewer/commenter/editor on " "their own personal views or not." ) - assert ( - expected_ops_checked_by_manager.difference( - set(ViewOwnershipPermissionManagerType().ops_checked_by_this_manager) - ) - == set() + assert expected_ops_checked_by_manager.difference( + set(ViewOwnershipPermissionManagerType().ops_checked_by_this_manager) + ) == set( + [ + # The read and update role operation types are not related to the + # personal views, so they don't have to be included. + ReadRoleViewOperationType.type, + UpdateRoleViewOperationType.type, + ] ) diff --git a/premium/web-frontend/modules/baserow_premium/components/views/ViewOwnershipMenuLink.vue b/premium/web-frontend/modules/baserow_premium/components/views/ViewOwnershipMenuLink.vue index 8aedd1ceef..541c46895a 100644 --- a/premium/web-frontend/modules/baserow_premium/components/views/ViewOwnershipMenuLink.vue +++ b/premium/web-frontend/modules/baserow_premium/components/views/ViewOwnershipMenuLink.vue @@ -1,5 +1,5 @@ + + diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json index 11ed5371b4..2ebaf3809c 100644 --- a/web-frontend/modules/core/locales/en.json +++ b/web-frontend/modules/core/locales/en.json @@ -150,6 +150,21 @@ "errorInvalidOldPasswordTitle": "Invalid password", "errorInvalidOldPasswordMessage": "Could not change your password because your old password is invalid." }, + "emailSettings": { + "title": "Change email address", + "successTitle": "Confirmation email sent", + "successDescription": "We've sent a confirmation email to your new address. Please check your inbox and click the link to complete the email change.", + "currentEmailLabel": "Current email address", + "newEmailLabel": "New email address", + "passwordLabel": "Current password", + "submitButton": "Send confirmation email", + "errorInvalidPasswordTitle": "Invalid password", + "errorInvalidPasswordMessage": "Could not request email change because your password is incorrect.", + "errorEmailExistsTitle": "Email already exists", + "errorEmailExistsMessage": "Could not request email change because an account with this email address already exists.", + "errorNotAllowedTitle": "Email change not allowed", + "errorNotAllowedMessage": "You cannot change your email address because your account uses single sign-on (SSO) authentication." + }, "deleteAccountSettings": { "title": "Delete account", "description": "You can schedule the deletion of your account by entering your current password and clicking the button. Your account will be permanently deleted after {days} days. In the meantime, if you log in again, your account deletion will be cancelled.", @@ -546,6 +561,20 @@ "disabled": "Reset Password is disabled", "disabledMessage": "It's not possible to reset the password because it has been disabled." }, + "changeEmail": { + "title": "Confirm email change", + "submit": "Confirm email change", + "changed": "Email address changed", + "message": "Your email address has been successfully changed. You can now login to Baserow using your new email address.", + "errorInvalidLinkTitle": "Invalid link", + "errorInvalidLinkMessage": "Could not change the email address because the link is invalid.", + "errorLinkExpiredTitle": "Link expired", + "errorLinkExpiredMessage": "The email change link has expired. Please request a new one from your account settings.", + "errorEmailExistsTitle": "Email already exists", + "errorEmailExistsMessage": "Could not change the email address because an account with this email address already exists.", + "errorEmailAlreadyChangedTitle": "Email already changed", + "errorEmailAlreadyChangedMessage": "The email address has already been changed to the requested address." + }, "signup": { "headTitle": "Create account", "title": "Sign up", diff --git a/web-frontend/modules/core/pages/changeEmail.vue b/web-frontend/modules/core/pages/changeEmail.vue new file mode 100644 index 0000000000..a9d0179450 --- /dev/null +++ b/web-frontend/modules/core/pages/changeEmail.vue @@ -0,0 +1,108 @@ + + + diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js index 2d1dab0c09..2d42e28890 100644 --- a/web-frontend/modules/core/plugin.js +++ b/web-frontend/modules/core/plugin.js @@ -14,6 +14,7 @@ import { import { AccountSettingsType, PasswordSettingsType, + EmailSettingsType, EmailNotificationsSettingsType, MCPEndpointSettingsType, DeleteAccountSettingsType, @@ -181,6 +182,7 @@ export default (context, inject) => { registry.register('settings', new AccountSettingsType(context)) registry.register('settings', new PasswordSettingsType(context)) + registry.register('settings', new EmailSettingsType(context)) registry.register('settings', new EmailNotificationsSettingsType(context)) registry.register('settings', new MCPEndpointSettingsType(context)) registry.register('settings', new DeleteAccountSettingsType(context)) diff --git a/web-frontend/modules/core/plugins.js b/web-frontend/modules/core/plugins.js index 6916d4a89c..48f0c3b45b 100644 --- a/web-frontend/modules/core/plugins.js +++ b/web-frontend/modules/core/plugins.js @@ -160,7 +160,7 @@ export class BaserowPlugin extends Registerable { * view context menu displayed at the top bar (three dots menu) in the View view. * @returns {*[]} */ - getAdditionalViewContextComponents(workspace, view) { + getAdditionalViewContextComponents(workspace, table, view) { return [] } diff --git a/web-frontend/modules/core/routes.js b/web-frontend/modules/core/routes.js index abfc419708..b7b273f4c7 100644 --- a/web-frontend/modules/core/routes.js +++ b/web-frontend/modules/core/routes.js @@ -32,6 +32,12 @@ export const routes = [ component: path.resolve(__dirname, 'pages/resetPassword.vue'), meta: { preventPageViewTracking: true }, }, + { + name: 'change-email', + path: '/change-email/:token', + component: path.resolve(__dirname, 'pages/changeEmail.vue'), + meta: { preventPageViewTracking: true }, + }, { name: 'verify-email-address', path: '/verify-email-address/:token', diff --git a/web-frontend/modules/core/services/auth.js b/web-frontend/modules/core/services/auth.js index 15442060b5..520c812f2c 100644 --- a/web-frontend/modules/core/services/auth.js +++ b/web-frontend/modules/core/services/auth.js @@ -60,6 +60,18 @@ export default (client) => { new_password: newPassword, }) }, + sendChangeEmailConfirmation(newEmail, password, baseUrl) { + return client.post('/user/send-change-email-confirmation/', { + new_email: newEmail, + password, + base_url: baseUrl, + }) + }, + changeEmail(token) { + return client.post('/user/change-email/', { + token, + }) + }, sendVerifyEmail(email) { return client.post(`/user/send-verify-email/`, { email, diff --git a/web-frontend/modules/core/settingsTypes.js b/web-frontend/modules/core/settingsTypes.js index f2923a1db4..fba3be643f 100644 --- a/web-frontend/modules/core/settingsTypes.js +++ b/web-frontend/modules/core/settingsTypes.js @@ -2,6 +2,7 @@ import { Registerable } from '@baserow/modules/core/registry' import PasswordSettings from '@baserow/modules/core/components/settings/PasswordSettings' import AccountSettings from '@baserow/modules/core/components/settings/AccountSettings' import DeleteAccountSettings from '@baserow/modules/core/components/settings/DeleteAccountSettings' +import EmailSettings from '@baserow/modules/core/components/settings/EmailSettings' import EmailNotifications from '@baserow/modules/core/components/settings/EmailNotifications' import McpEndpointSettings from '@baserow/modules/core/components/settings/McpEndpointSettings.vue' import TwoFactorAuthSettings from '@baserow/modules/core/components/settings/TwoFactorAuthSettings.vue' @@ -117,6 +118,29 @@ export class PasswordSettingsType extends SettingsType { } } +export class EmailSettingsType extends SettingsType { + static getType() { + return 'change_email' + } + + getIconClass() { + return 'iconoir-at-sign' + } + + getName() { + const { i18n } = this.app + return i18n.t('settingType.email') + } + + isEnabled() { + return this.app.store.getters['authProvider/getPasswordLoginEnabled'] + } + + getComponent() { + return EmailSettings + } +} + export class EmailNotificationsSettingsType extends SettingsType { static getType() { return 'email-notifications' diff --git a/web-frontend/modules/database/components/view/ViewContext.vue b/web-frontend/modules/database/components/view/ViewContext.vue index 2e60281258..9af94983e2 100644 --- a/web-frontend/modules/database/components/view/ViewContext.vue +++ b/web-frontend/modules/database/components/view/ViewContext.vue @@ -2,6 +2,16 @@
{{ view.name }} ({{ view.id }})
    +
  • - - + + + - + @@ -118,6 +138,11 @@ export default { .slice() .sort((a, b) => b.getListViewTypeSort() - a.getListViewTypeSort()) }, + clickOnDeactivatedItem(event, type) { + if (type.isDeactivated(this.database.workspace.id)) { + this.$refs[`deactivatedClickModal-${type.getType()}`][0].show() + } + }, }, } diff --git a/web-frontend/modules/database/components/view/ViewOwnershipRadio.vue b/web-frontend/modules/database/components/view/ViewOwnershipRadio.vue deleted file mode 100644 index 65fa6e44c8..0000000000 --- a/web-frontend/modules/database/components/view/ViewOwnershipRadio.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/web-frontend/modules/database/components/view/ViewsContext.vue b/web-frontend/modules/database/components/view/ViewsContext.vue index 014927c905..bbfffd6b0d 100644 --- a/web-frontend/modules/database/components/view/ViewsContext.vue +++ b/web-frontend/modules/database/components/view/ViewsContext.vue @@ -142,11 +142,9 @@ export default { ) }, activeViewOwnershipTypes() { - return Object.values(this.viewOwnershipTypes) - .filter( - (type) => type.isDeactivated(this.database.workspace.id) === false - ) - .sort((a, b) => a.getListViewTypeSort() - b.getListViewTypeSort()) + return Object.values(this.viewOwnershipTypes).sort( + (a, b) => a.getListViewTypeSort() - b.getListViewTypeSort() + ) }, }, methods: { diff --git a/web-frontend/modules/database/components/view/ViewsContextItem.vue b/web-frontend/modules/database/components/view/ViewsContextItem.vue index 4b3ecf0b2f..479f048d8c 100644 --- a/web-frontend/modules/database/components/view/ViewsContextItem.vue +++ b/web-frontend/modules/database/components/view/ViewsContextItem.vue @@ -17,7 +17,10 @@ -
    +
    @@ -86,8 +89,8 @@ export default { viewType() { return this.$registry.get('view', this.view.type) }, - deactivatedText() { - return this.viewType.getDeactivatedText({ view: this.view }) + viewOwnershipType() { + return this.$registry.get('viewOwnershipType', this.view.ownership_type) }, deactivated() { return ( @@ -95,8 +98,26 @@ export default { this.viewType.isDeactivated(this.database.workspace.id) ) }, + viewOwnershipDeactivated() { + return this.viewOwnershipType.isDeactivated(this.database.workspace.id) + }, + deactivatedText() { + if (this.deactivated) { + return this.viewType.getDeactivatedText({ view: this.view }) + } + if (this.viewOwnershipDeactivated) { + return this.viewOwnershipType.getDeactivatedText() + } + return null + }, deactivatedClickModal() { - return this.deactivated ? this.viewType.getDeactivatedClickModal() : null + if (this.deactivated) { + return this.viewType.getDeactivatedClickModal() + } + if (this.viewOwnershipDeactivated) { + return this.viewOwnershipType.getDeactivatedModal() + } + return null }, showViewContext() { return ( @@ -138,7 +159,7 @@ export default { this.$refs.rename.edit() }, select(view) { - if (this.deactivated) { + if (this.deactivated || this.viewOwnershipDeactivated) { this.$refs.deactivatedClickModal.show() return } diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json index 0329240e61..439d286744 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -644,11 +644,11 @@ }, "viewForm": { "name": "Name", - "whoCanEdit": "Who can edit" + "whoCanEdit": "Who can edit this view?" }, "viewOwnershipType": { "collaborative": "Collaborative", - "personal": "Personal" + "collaborativeDescription": "Everyone can see all the data and change the properties if they have the permissions." }, "galleryViewHeader": { "customizeCards": "Customize cards" diff --git a/web-frontend/modules/database/viewOwnershipTypes.js b/web-frontend/modules/database/viewOwnershipTypes.js index 6ae6ab37d0..918413f050 100644 --- a/web-frontend/modules/database/viewOwnershipTypes.js +++ b/web-frontend/modules/database/viewOwnershipTypes.js @@ -1,5 +1,4 @@ import { Registerable } from '@baserow/modules/core/registry' -import ViewOwnershipRadioVue from './components/view/ViewOwnershipRadio.vue' export class ViewOwnershipType extends Registerable { /** @@ -9,6 +8,14 @@ export class ViewOwnershipType extends Registerable { return null } + /** + * A short human readable description of the view ownership type that will be + * shown to the user when creating the view. + */ + getDescription() { + return null + } + /** * A human readable name of the feature it belongs to. */ @@ -23,13 +30,6 @@ export class ViewOwnershipType extends Registerable { return null } - /* - * Radio component used in view ownership forms. - */ - getRadioComponent() { - return ViewOwnershipRadioVue - } - /** * Indicates if the view ownership type is disabled. */ @@ -69,8 +69,21 @@ export class ViewOwnershipType extends Registerable { } } + /** + * A component that's added to the context menu of the view that can be used to, + * for example, change the ownership type of the view. By default, it doesn't + * register a component. + */ + getChangeOwnershipTypeMenuItemComponent() { + return null + } + userCanTryCreate(table, workspaceId) { - return false + return this.app.$hasPermission( + 'database.table.create_view', + table, + workspaceId + ) } } @@ -84,28 +97,12 @@ export class CollaborativeViewOwnershipType extends ViewOwnershipType { return i18n.t('viewOwnershipType.collaborative') } - getIconClass() { - return 'iconoir-community' - } - - isDeactivated(workspaceId) { - return false - } - - /** - * This should return nothing, because collaborative views should know nothing - * about other ownership types and if you're in a collaborative view, you - * should not be able to switch to a different view type. - */ - getChangeOwnershipTypeMenuItemComponent() { - return null + getDescription() { + const { i18n } = this.app + return i18n.t('viewOwnershipType.collaborativeDescription') } - userCanTryCreate(table, workspaceId) { - return this.app.$hasPermission( - 'database.table.create_view', - table, - workspaceId - ) + getIconClass() { + return 'iconoir-group' } }