Skip to content

Commit 05ec05a

Browse files
authored
Allow change account email (baserow#4329)
1 parent 0596903 commit 05ec05a

File tree

25 files changed

+1494
-11
lines changed

25 files changed

+1494
-11
lines changed

backend/src/baserow/api/user/errors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,15 @@
7979
HTTP_400_BAD_REQUEST,
8080
"The provided refresh token is already blacklisted.",
8181
)
82+
83+
ERROR_CHANGE_EMAIL_NOT_ALLOWED = (
84+
"ERROR_CHANGE_EMAIL_NOT_ALLOWED",
85+
HTTP_400_BAD_REQUEST,
86+
"Email changes are only allowed for password-based accounts.",
87+
)
88+
89+
ERROR_EMAIL_ALREADY_CHANGED = (
90+
"ERROR_EMAIL_ALREADY_CHANGED",
91+
HTTP_400_BAD_REQUEST,
92+
"The email address has already been changed to the requested address.",
93+
)

backend/src/baserow/api/user/serializers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,22 @@ class ChangePasswordBodyValidationSerializer(serializers.Serializer):
246246
new_password = serializers.CharField(validators=[password_validation])
247247

248248

249+
class SendChangeEmailConfirmationSerializer(serializers.Serializer):
250+
new_email = serializers.EmailField(help_text="The new email address to change to.")
251+
password = serializers.CharField(
252+
help_text="The current password of the user for verification."
253+
)
254+
base_url = serializers.URLField(
255+
help_text="The base URL where the user can confirm the email change. The "
256+
"confirmation token is going to be appended to the base_url "
257+
"(base_url '/token')."
258+
)
259+
260+
261+
class ChangeEmailSerializer(serializers.Serializer):
262+
token = serializers.CharField(help_text="The confirmation token.")
263+
264+
249265
class VerifyEmailAddressSerializer(serializers.Serializer):
250266
token = serializers.CharField()
251267

backend/src/baserow/api/user/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
from .views import (
44
AccountView,
55
BlacklistJSONWebToken,
6+
ChangeEmailView,
67
ChangePasswordView,
78
DashboardView,
89
ObtainJSONWebToken,
910
RedoView,
1011
RefreshJSONWebToken,
1112
ResetPasswordView,
1213
ScheduleAccountDeletionView,
14+
SendChangeEmailConfirmationView,
1315
SendResetPasswordEmailView,
1416
SendVerifyEmailView,
1517
ShareOnboardingDetailsWithBaserowView,
@@ -43,6 +45,12 @@
4345
re_path(
4446
r"^change-password/$", ChangePasswordView.as_view(), name="change_password"
4547
),
48+
re_path(
49+
r"^send-change-email-confirmation/$",
50+
SendChangeEmailConfirmationView.as_view(),
51+
name="send_change_email_confirmation",
52+
),
53+
re_path(r"^change-email/$", ChangeEmailView.as_view(), name="change_email"),
4654
re_path(
4755
r"^send-verify-email/$", SendVerifyEmailView.as_view(), name="send_verify_email"
4856
),

backend/src/baserow/api/user/views.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,22 @@
5757
from baserow.core.handler import CoreHandler
5858
from baserow.core.models import Settings, Template, WorkspaceInvitation
5959
from baserow.core.user.actions import (
60+
ChangeEmailActionType,
6061
ChangeUserPasswordActionType,
6162
CreateUserActionType,
6263
ResetUserPasswordActionType,
6364
ScheduleUserDeletionActionType,
65+
SendChangeEmailConfirmationActionType,
6466
SendResetUserPasswordActionType,
6567
SendVerifyEmailAddressActionType,
6668
UpdateUserActionType,
6769
VerifyEmailAddressActionType,
6870
)
6971
from baserow.core.user.exceptions import (
72+
ChangeEmailNotAllowed,
7073
DeactivatedUserException,
7174
DisabledSignupError,
75+
EmailAlreadyChanged,
7276
EmailAlreadyVerified,
7377
InvalidPassword,
7478
InvalidVerificationToken,
@@ -84,10 +88,12 @@
8488
from .errors import (
8589
ERROR_ALREADY_EXISTS,
8690
ERROR_AUTH_PROVIDER_DISABLED,
91+
ERROR_CHANGE_EMAIL_NOT_ALLOWED,
8792
ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET,
8893
ERROR_DEACTIVATED_USER,
8994
ERROR_DISABLED_RESET_PASSWORD,
9095
ERROR_DISABLED_SIGNUP,
96+
ERROR_EMAIL_ALREADY_CHANGED,
9197
ERROR_EMAIL_ALREADY_VERIFIED,
9298
ERROR_EMAIL_VERIFICATION_REQUIRED,
9399
ERROR_INVALID_CREDENTIALS,
@@ -107,10 +113,12 @@
107113
)
108114
from .serializers import (
109115
AccountSerializer,
116+
ChangeEmailSerializer,
110117
ChangePasswordBodyValidationSerializer,
111118
DashboardSerializer,
112119
RegisterSerializer,
113120
ResetPasswordBodyValidationSerializer,
121+
SendChangeEmailConfirmationSerializer,
114122
SendResetPasswordEmailBodyValidationSerializer,
115123
SendVerifyEmailAddressSerializer,
116124
ShareOnboardingDetailsWithBaserowSerializer,
@@ -482,6 +490,104 @@ def post(self, request, data):
482490
return Response(status=204)
483491

484492

493+
class SendChangeEmailConfirmationView(APIView):
494+
permission_classes = (IsAuthenticated,)
495+
496+
@extend_schema(
497+
tags=["User"],
498+
request=SendChangeEmailConfirmationSerializer,
499+
operation_id="send_change_email_confirmation",
500+
description=(
501+
"Sends an email to the new email address containing a confirmation link. "
502+
"The user must provide their current password to initiate this request. "
503+
f"The link is going to be valid for "
504+
f"{int(settings.CHANGE_EMAIL_TOKEN_MAX_AGE / 60 / 60)} hours."
505+
),
506+
responses={
507+
204: None,
508+
400: get_error_schema(
509+
[
510+
"ERROR_REQUEST_BODY_VALIDATION",
511+
"ERROR_HOSTNAME_IS_NOT_ALLOWED",
512+
"ERROR_INVALID_OLD_PASSWORD",
513+
"ERROR_ALREADY_EXISTS",
514+
"ERROR_CHANGE_EMAIL_NOT_ALLOWED",
515+
"ERROR_AUTH_PROVIDER_DISABLED",
516+
]
517+
),
518+
},
519+
)
520+
@transaction.atomic
521+
@map_exceptions(
522+
{
523+
BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED,
524+
InvalidPassword: ERROR_INVALID_OLD_PASSWORD,
525+
UserAlreadyExist: ERROR_ALREADY_EXISTS,
526+
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
527+
ChangeEmailNotAllowed: ERROR_CHANGE_EMAIL_NOT_ALLOWED,
528+
}
529+
)
530+
@validate_body(SendChangeEmailConfirmationSerializer)
531+
def post(self, request, data):
532+
"""
533+
Sends a confirmation email to the new email address if the password is correct.
534+
"""
535+
536+
action_type_registry.get(SendChangeEmailConfirmationActionType.type).do(
537+
request.user, data["new_email"], data["password"], data["base_url"]
538+
)
539+
540+
return Response(status=204)
541+
542+
543+
class ChangeEmailView(APIView):
544+
permission_classes = (AllowAny,)
545+
546+
@extend_schema(
547+
tags=["User"],
548+
request=ChangeEmailSerializer,
549+
operation_id="change_email",
550+
description=(
551+
"Changes the email address of a user if the confirmation token is valid. "
552+
"The **send_change_email_confirmation** endpoint sends an email to the "
553+
"new address containing the token. That token can be used to change the "
554+
"email address here."
555+
),
556+
responses={
557+
204: None,
558+
400: get_error_schema(
559+
[
560+
"BAD_TOKEN_SIGNATURE",
561+
"EXPIRED_TOKEN_SIGNATURE",
562+
"ERROR_USER_NOT_FOUND",
563+
"ERROR_ALREADY_EXISTS",
564+
"ERROR_EMAIL_ALREADY_CHANGED",
565+
"ERROR_REQUEST_BODY_VALIDATION",
566+
]
567+
),
568+
},
569+
auth=[],
570+
)
571+
@transaction.atomic
572+
@map_exceptions(
573+
{
574+
BadSignature: BAD_TOKEN_SIGNATURE,
575+
BadTimeSignature: BAD_TOKEN_SIGNATURE,
576+
SignatureExpired: EXPIRED_TOKEN_SIGNATURE,
577+
UserNotFound: ERROR_USER_NOT_FOUND,
578+
UserAlreadyExist: ERROR_ALREADY_EXISTS,
579+
EmailAlreadyChanged: ERROR_EMAIL_ALREADY_CHANGED,
580+
}
581+
)
582+
@validate_body(ChangeEmailSerializer)
583+
def post(self, request, data):
584+
"""Changes the user's email address if the provided token is valid."""
585+
586+
action_type_registry.get(ChangeEmailActionType.type).do(data["token"])
587+
588+
return Response(status=204)
589+
590+
485591
class AccountView(APIView):
486592
permission_classes = (IsAuthenticated,)
487593

backend/src/baserow/config/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,7 @@ def __setitem__(self, key, value):
779779

780780
FROM_EMAIL = os.getenv("FROM_EMAIL", "no-reply@localhost")
781781
RESET_PASSWORD_TOKEN_MAX_AGE = 60 * 60 * 48 # 48 hours
782+
CHANGE_EMAIL_TOKEN_MAX_AGE = 60 * 60 * 12 # 12 hours
782783

783784
ROW_PAGE_SIZE_LIMIT = int(os.getenv("BASEROW_ROW_PAGE_SIZE_LIMIT", 200))
784785
BATCH_ROWS_SIZE_LIMIT = int(

backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ msgid ""
88
msgstr ""
99
"Project-Id-Version: PACKAGE VERSION\n"
1010
"Report-Msgid-Bugs-To: \n"
11-
"POT-Creation-Date: 2025-11-17 15:17+0000\n"
11+
"POT-Creation-Date: 2025-11-25 13:02+0000\n"
1212
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
1313
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
1414
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -46,7 +46,7 @@ msgstr ""
4646
msgid "Last name"
4747
msgstr ""
4848

49-
#: src/baserow/contrib/builder/data_providers/data_provider_types.py:619
49+
#: src/baserow/contrib/builder/data_providers/data_provider_types.py:621
5050
#, python-format
5151
msgid "%(user_source_name)s member"
5252
msgstr ""

backend/src/baserow/core/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,12 @@ def ready(self):
323323

324324
from baserow.core.user.actions import (
325325
CancelUserDeletionActionType,
326+
ChangeEmailActionType,
326327
ChangeUserPasswordActionType,
327328
CreateUserActionType,
328329
ResetUserPasswordActionType,
329330
ScheduleUserDeletionActionType,
331+
SendChangeEmailConfirmationActionType,
330332
SendResetUserPasswordActionType,
331333
SendVerifyEmailAddressActionType,
332334
SignInUserActionType,
@@ -344,6 +346,8 @@ def ready(self):
344346
action_type_registry.register(ResetUserPasswordActionType())
345347
action_type_registry.register(SendVerifyEmailAddressActionType())
346348
action_type_registry.register(VerifyEmailAddressActionType())
349+
action_type_registry.register(SendChangeEmailConfirmationActionType())
350+
action_type_registry.register(ChangeEmailActionType())
347351

348352
from baserow.core.action.scopes import (
349353
ApplicationActionScopeType,

backend/src/baserow/core/locale/en/LC_MESSAGES/django.po

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ msgid ""
88
msgstr ""
99
"Project-Id-Version: PACKAGE VERSION\n"
1010
"Report-Msgid-Bugs-To: \n"
11-
"POT-Creation-Date: 2025-11-17 15:17+0000\n"
11+
"POT-Creation-Date: 2025-11-25 13:02+0000\n"
1212
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
1313
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
1414
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -108,8 +108,8 @@ msgstr ""
108108
#, python-format
109109
msgid ""
110110
"Application \"%(application_name)s\" (%(application_id)s) of type "
111-
"%(application_type)s duplicated from \"%(original_application_name)s\" "
112-
"(%(original_application_id)s)"
111+
"%(application_type)s duplicated from "
112+
"\"%(original_application_name)s\" (%(original_application_id)s)"
113113
msgstr ""
114114

115115
#: src/baserow/core/actions.py:709
@@ -130,8 +130,8 @@ msgstr ""
130130
#: src/baserow/core/actions.py:796
131131
#, python-format
132132
msgid ""
133-
"Group invitation created for \"%(email)s\" to join \"%(group_name)s\" "
134-
"(%(group_id)s) as %(permissions)s."
133+
"Group invitation created for \"%(email)s\" to join "
134+
"\"%(group_name)s\" (%(group_id)s) as %(permissions)s."
135135
msgstr ""
136136

137137
#: src/baserow/core/actions.py:851
@@ -242,7 +242,7 @@ msgstr ""
242242
msgid "Decimal number"
243243
msgstr ""
244244

245-
#: src/baserow/core/handler.py:2187 src/baserow/core/user/handler.py:267
245+
#: src/baserow/core/handler.py:2187 src/baserow/core/user/handler.py:269
246246
#, python-format
247247
msgid "%(name)s's workspace"
248248
msgstr ""
@@ -341,6 +341,7 @@ msgstr[1] ""
341341
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:188
342342
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:188
343343
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:193
344+
#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:211
344345
#: src/baserow/core/templates/baserow/core/user/reset_password.html:211
345346
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:215
346347
msgid ""
@@ -389,6 +390,30 @@ msgid ""
389390
"just have to login again."
390391
msgstr ""
391392

393+
#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:176
394+
msgid "Confirm email address change"
395+
msgstr ""
396+
397+
#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:181
398+
#, python-format
399+
msgid ""
400+
"A request was made to change the email address for your Baserow account from "
401+
"%(old_email)s to %(new_email)s on Baserow "
402+
"(%(baserow_embedded_share_hostname)s). If you did not authorize this, you "
403+
"may simply ignore this email."
404+
msgstr ""
405+
406+
#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:186
407+
#, python-format
408+
msgid ""
409+
"To confirm your email address change, simply click the button below. This "
410+
"link will expire in %(hours)s hours."
411+
msgstr ""
412+
413+
#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:195
414+
msgid "Confirm email change"
415+
msgstr ""
416+
392417
#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:181
393418
msgid "Thank you for using Baserow"
394419
msgstr ""
@@ -496,8 +521,9 @@ msgstr ""
496521
#: src/baserow/core/user/actions.py:27
497522
#, python-format
498523
msgid ""
499-
"User \"%(user_email)s\" (%(user_id)s) created via \"%(auth_provider_type)s\" "
500-
"(%(auth_provider_id)s) auth provider (invitation: %(with_invitation_token)s)"
524+
"User \"%(user_email)s\" (%(user_id)s) created via "
525+
"\"%(auth_provider_type)s\" (%(auth_provider_id)s) auth provider (invitation: "
526+
"%(with_invitation_token)s)"
501527
msgstr ""
502528

503529
#: src/baserow/core/user/actions.py:119
@@ -587,6 +613,28 @@ msgstr ""
587613
msgid "User \"%(user_email)s\" (%(user_id)s) verify email"
588614
msgstr ""
589615

616+
#: src/baserow/core/user/actions.py:510
617+
msgid "Send change email confirmation"
618+
msgstr ""
619+
620+
#: src/baserow/core/user/actions.py:512
621+
#, python-format
622+
msgid ""
623+
"User \"%(user_email)s\" (%(user_id)s) requested to change email to "
624+
"\"%(new_email)s\""
625+
msgstr ""
626+
627+
#: src/baserow/core/user/actions.py:558
628+
msgid "Change email"
629+
msgstr ""
630+
631+
#: src/baserow/core/user/actions.py:560
632+
#, python-format
633+
msgid ""
634+
"User \"%(old_email)s\" (%(user_id)s) changed email to \"%(new_email)s\" by "
635+
"using the token."
636+
msgstr ""
637+
590638
#: src/baserow/core/user/emails.py:16
591639
msgid "Reset password - Baserow"
592640
msgstr ""
@@ -602,3 +650,7 @@ msgstr ""
602650
#: src/baserow/core/user/emails.py:74
603651
msgid "Account deletion cancelled - Baserow"
604652
msgstr ""
653+
654+
#: src/baserow/core/user/emails.py:94
655+
msgid "Confirm email address change - Baserow"
656+
msgstr ""

0 commit comments

Comments
 (0)