Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/src/baserow/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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."""
Expand Down
12 changes: 12 additions & 0 deletions backend/src/baserow/api/user/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
16 changes: 16 additions & 0 deletions backend/src/baserow/api/user/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 8 additions & 0 deletions backend/src/baserow/api/user/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from .views import (
AccountView,
BlacklistJSONWebToken,
ChangeEmailView,
ChangePasswordView,
DashboardView,
ObtainJSONWebToken,
RedoView,
RefreshJSONWebToken,
ResetPasswordView,
ScheduleAccountDeletionView,
SendChangeEmailConfirmationView,
SendResetPasswordEmailView,
SendVerifyEmailView,
ShareOnboardingDetailsWithBaserowView,
Expand Down Expand Up @@ -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"
),
Expand Down
106 changes: 106 additions & 0 deletions backend/src/baserow/api/user/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -107,10 +113,12 @@
)
from .serializers import (
AccountSerializer,
ChangeEmailSerializer,
ChangePasswordBodyValidationSerializer,
DashboardSerializer,
RegisterSerializer,
ResetPasswordBodyValidationSerializer,
SendChangeEmailConfirmationSerializer,
SendResetPasswordEmailBodyValidationSerializer,
SendVerifyEmailAddressSerializer,
ShareOnboardingDetailsWithBaserowSerializer,
Expand Down Expand Up @@ -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,)

Expand Down
1 change: 1 addition & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand Down Expand Up @@ -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 ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/contrib/database/airtable/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions backend/src/baserow/contrib/database/application_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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,
Expand All @@ -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}"
)
Expand Down
12 changes: 10 additions & 2 deletions backend/src/baserow/contrib/database/views/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
Loading
Loading