1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-05-12 04:11:49 +00:00

Email verification

This commit is contained in:
Petr Stribny 2024-04-25 08:34:01 +00:00
parent 7836f35605
commit 69988b98d0
45 changed files with 2033 additions and 388 deletions
backend
changelog/entries/unreleased/feature
docs/patterns
enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES
premium/backend/src/baserow_premium/locale/en/LC_MESSAGES
web-frontend/modules/core

View file

@ -38,6 +38,7 @@ class SettingsSerializer(serializers.ModelSerializer):
"track_workspace_usage",
"show_baserow_help_request",
"co_branding_logo",
"email_verification",
)
extra_kwargs = {
"allow_new_signups": {"required": False},
@ -47,8 +48,29 @@ class SettingsSerializer(serializers.ModelSerializer):
"account_deletion_grace_delay": {"required": False},
"track_workspace_usage": {"required": False},
"show_baserow_help_request": {"required": False},
"email_verification": {"required": False},
}
def to_representation(self, instance):
representation = super().to_representation(instance)
# TODO Remove in a future release once email_verification is null=False
if representation["email_verification"] is None:
representation[
"email_verification"
] = Settings.EmailVerificationOptions.NO_VERIFICATION
return representation
def to_internal_value(self, data):
# TODO Remove in a future release once email_verification is null=False
if "email_verification" in data and data["email_verification"] is None:
raise serializers.ValidationError(
detail={"email_verification": ["'null' is not a valid choice."]},
code="invalid_choice",
)
return super().to_internal_value(data)
class InstanceIdSerializer(serializers.ModelSerializer):
class Meta:

View file

@ -20,6 +20,17 @@ ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET = (
)
ERROR_DISABLED_RESET_PASSWORD = "ERROR_DISABLED_RESET_PASSWORD" # nosec
ERROR_EMAIL_ALREADY_VERIFIED = (
"ERROR_EMAIL_ALREADY_VERIFIED",
HTTP_400_BAD_REQUEST,
"The user's email is verified already.",
)
ERROR_INVALID_VERIFICATION_TOKEN = (
"ERROR_INVALID_VERIFICATION_TOKEN",
HTTP_400_BAD_REQUEST,
"A valid verification token is required.",
)
ERROR_UNDO_REDO_LOCK_CONFLICT = (
"ERROR_UNDO_REDO_LOCK_CONFLICT",
HTTP_409_CONFLICT,
@ -57,6 +68,12 @@ ERROR_AUTH_PROVIDER_DISABLED = (
"Authentication provider is disabled.",
)
ERROR_EMAIL_VERIFICATION_REQUIRED = (
"ERROR_EMAIL_VERIFICATION_REQUIRED",
HTTP_401_UNAUTHORIZED,
"Email address has to be verified first.",
)
ERROR_REFRESH_TOKEN_ALREADY_BLACKLISTED = (
"ERROR_REFRESH_TOKEN_ALREADY_BLACKLISTED",
HTTP_400_BAD_REQUEST,

View file

@ -23,11 +23,16 @@ from baserow.api.workspaces.invitations.serializers import (
UserWorkspaceInvitationSerializer,
)
from baserow.core.action.registries import action_type_registry
from baserow.core.auth_provider.exceptions import AuthProviderDisabled
from baserow.core.auth_provider.exceptions import (
AuthProviderDisabled,
EmailVerificationRequired,
)
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.models import Template, UserProfile
from baserow.core.handler import CoreHandler
from baserow.core.models import Settings, Template, UserProfile
from baserow.core.user.actions import SignInUserActionType
from baserow.core.user.exceptions import DeactivatedUserException
from baserow.core.user.handler import UserHandler
from baserow.core.user.utils import (
generate_session_tokens_for_user,
normalize_email_address,
@ -72,6 +77,10 @@ class UserSerializer(serializers.ModelSerializer):
help_text="The maximum frequency at which the user wants to "
"receive email notifications.",
)
email_verified = serializers.BooleanField(
source="profile.email_verified",
help_text="Shows whether the user's email has been verified.",
)
class Meta:
model = User
@ -83,11 +92,13 @@ class UserSerializer(serializers.ModelSerializer):
"id",
"language",
"email_notification_frequency",
"email_verified",
)
extra_kwargs = {
"password": {"write_only": True},
"is_staff": {"read_only": True},
"id": {"read_only": True},
"email_verified": {"read_only": True},
}
@ -205,6 +216,14 @@ class ChangePasswordBodyValidationSerializer(serializers.Serializer):
new_password = serializers.CharField(validators=[password_validation])
class VerifyEmailAddressSerializer(serializers.Serializer):
token = serializers.CharField()
class SendVerifyEmailAddressSerializer(serializers.Serializer):
email = serializers.EmailField()
class NormalizedEmailField(serializers.EmailField):
def to_internal_value(self, data):
data = super().to_internal_value(data)
@ -267,7 +286,19 @@ class TokenObtainPairWithUserSerializer(TokenObtainPairSerializer):
if not user.is_active:
raise DeactivatedUserException()
data = generate_session_tokens_for_user(user, include_refresh_token=True)
settings = CoreHandler().get_settings()
if (
settings.email_verification == Settings.EmailVerificationOptions.ENFORCED
and not user.profile.email_verified
):
UserHandler().send_email_pending_verification(user)
raise EmailVerificationRequired()
data = generate_session_tokens_for_user(
user,
include_refresh_token=True,
verified_email_claim=Settings.EmailVerificationOptions.ENFORCED,
)
data.update(**get_all_user_data_serialized(user, self.context["request"]))
set_user_session_data_from_request(user, self.context["request"])
@ -294,6 +325,17 @@ class TokenRefreshWithUserSerializer(TokenRefreshSerializer):
user = get_user_from_token(
attrs["refresh"], RefreshToken, check_if_refresh_token_is_blacklisted=True
)
token = RefreshToken(attrs["refresh"])
settings = CoreHandler().get_settings()
if (
settings.email_verification == Settings.EmailVerificationOptions.ENFORCED
and not user.profile.email_verified
and token.get("verified_email_claim")
== Settings.EmailVerificationOptions.ENFORCED
):
raise EmailVerificationRequired()
data = generate_session_tokens_for_user(user)
data.update(**get_all_user_data_serialized(user, self.context["request"]))
token_refreshes_counter.add(1)
@ -319,6 +361,17 @@ class TokenVerifyWithUserSerializer(TokenVerifySerializer):
token_class=RefreshToken,
check_if_refresh_token_is_blacklisted=True,
)
token = RefreshToken(refresh_token)
settings = CoreHandler().get_settings()
if (
settings.email_verification == Settings.EmailVerificationOptions.ENFORCED
and not user.profile.email_verified
and token.get("verified_email_claim")
== Settings.EmailVerificationOptions.ENFORCED
):
raise EmailVerificationRequired()
return get_all_user_data_serialized(user, self.context["request"])

View file

@ -11,8 +11,10 @@ from .views import (
ResetPasswordView,
ScheduleAccountDeletionView,
SendResetPasswordEmailView,
SendVerifyEmailView,
UndoView,
UserView,
VerifyEmailAddressView,
VerifyJSONWebToken,
)
@ -40,6 +42,10 @@ urlpatterns = [
re_path(
r"^change-password/$", ChangePasswordView.as_view(), name="change_password"
),
re_path(
r"^send-verify-email/$", SendVerifyEmailView.as_view(), name="send_verify_email"
),
re_path(r"^verify-email/$", VerifyEmailAddressView.as_view(), name="verify_email"),
re_path(r"^dashboard/$", DashboardView.as_view(), name="dashboard"),
re_path(r"^undo/$", UndoView.as_view(), name="undo"),
re_path(r"^redo/$", RedoView.as_view(), name="redo"),

View file

@ -1,6 +1,7 @@
from typing import List
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
@ -42,7 +43,10 @@ from baserow.api.workspaces.invitations.errors import (
)
from baserow.core.action.handler import ActionHandler
from baserow.core.action.registries import ActionScopeStr, action_type_registry
from baserow.core.auth_provider.exceptions import AuthProviderDisabled
from baserow.core.auth_provider.exceptions import (
AuthProviderDisabled,
EmailVerificationRequired,
)
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed,
@ -50,19 +54,24 @@ from baserow.core.exceptions import (
WorkspaceInvitationDoesNotExist,
WorkspaceInvitationEmailMismatch,
)
from baserow.core.models import Template, WorkspaceInvitation
from baserow.core.handler import CoreHandler
from baserow.core.models import Settings, Template, WorkspaceInvitation
from baserow.core.user.actions import (
ChangeUserPasswordActionType,
CreateUserActionType,
ResetUserPasswordActionType,
ScheduleUserDeletionActionType,
SendResetUserPasswordActionType,
SendVerifyEmailAddressActionType,
UpdateUserActionType,
VerifyEmailAddressActionType,
)
from baserow.core.user.exceptions import (
DeactivatedUserException,
DisabledSignupError,
EmailAlreadyVerified,
InvalidPassword,
InvalidVerificationToken,
RefreshTokenAlreadyBlacklisted,
ResetPasswordDisabledError,
UserAlreadyExist,
@ -79,9 +88,12 @@ from .errors import (
ERROR_DEACTIVATED_USER,
ERROR_DISABLED_RESET_PASSWORD,
ERROR_DISABLED_SIGNUP,
ERROR_EMAIL_ALREADY_VERIFIED,
ERROR_EMAIL_VERIFICATION_REQUIRED,
ERROR_INVALID_CREDENTIALS,
ERROR_INVALID_OLD_PASSWORD,
ERROR_INVALID_REFRESH_TOKEN,
ERROR_INVALID_VERIFICATION_TOKEN,
ERROR_REFRESH_TOKEN_ALREADY_BLACKLISTED,
ERROR_UNDO_REDO_LOCK_CONFLICT,
ERROR_USER_IS_LAST_ADMIN,
@ -100,13 +112,16 @@ from .serializers import (
RegisterSerializer,
ResetPasswordBodyValidationSerializer,
SendResetPasswordEmailBodyValidationSerializer,
SendVerifyEmailAddressSerializer,
TokenBlacklistSerializer,
TokenObtainPairWithUserSerializer,
TokenRefreshWithUserSerializer,
TokenVerifyWithUserSerializer,
UserSerializer,
VerifyEmailAddressSerializer,
)
User = get_user_model()
UndoRedoRequestSerializer = get_undo_request_serializer()
@ -128,10 +143,14 @@ class ObtainJSONWebToken(TokenObtainPairView):
),
responses={
200: create_user_response_schema,
401: {
"description": "An active user with the provided email and password "
"could not be found."
},
401: get_error_schema(
[
"ERROR_INVALID_CREDENTIALS",
"ERROR_DEACTIVATED_USER",
"ERROR_AUTH_PROVIDER_DISABLED",
"ERROR_EMAIL_VERIFICATION_REQUIRED",
]
),
},
auth=[],
)
@ -140,6 +159,7 @@ class ObtainJSONWebToken(TokenObtainPairView):
AuthenticationFailed: ERROR_INVALID_CREDENTIALS,
DeactivatedUserException: ERROR_DEACTIVATED_USER,
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
EmailVerificationRequired: ERROR_EMAIL_VERIFICATION_REQUIRED,
}
)
def post(self, *args, **kwargs):
@ -158,11 +178,18 @@ class RefreshJSONWebToken(TokenRefreshView):
),
responses={
200: authenticate_user_schema,
401: {"description": "The JWT refresh token is invalid or expired."},
401: get_error_schema(
["ERROR_INVALID_REFRESH_TOKEN", "ERROR_EMAIL_VERIFICATION_REQUIRED"]
),
},
auth=[],
)
@map_exceptions({InvalidToken: ERROR_INVALID_REFRESH_TOKEN})
@map_exceptions(
{
InvalidToken: ERROR_INVALID_REFRESH_TOKEN,
EmailVerificationRequired: ERROR_EMAIL_VERIFICATION_REQUIRED,
}
)
def post(self, *args, **kwargs):
return super().post(*args, **kwargs)
@ -179,11 +206,18 @@ class VerifyJSONWebToken(TokenVerifyView):
),
responses={
200: verify_user_schema,
401: {"description": "The JWT refresh token is invalid or expired."},
401: get_error_schema(
["ERROR_INVALID_REFRESH_TOKEN", "ERROR_EMAIL_VERIFICATION_REQUIRED"]
),
},
auth=[],
)
@map_exceptions({InvalidToken: ERROR_INVALID_REFRESH_TOKEN})
@map_exceptions(
{
InvalidToken: ERROR_INVALID_REFRESH_TOKEN,
EmailVerificationRequired: ERROR_EMAIL_VERIFICATION_REQUIRED,
}
)
def post(self, *args, **kwargs):
return super().post(*args, **kwargs)
@ -287,15 +321,21 @@ class UserView(APIView):
response = {"user": UserSerializer(user).data}
if data["authenticate"]:
response.update(
{
**generate_session_tokens_for_user(
user, include_refresh_token=True
),
**user_data_registry.get_all_user_data(user, request),
}
)
settings = CoreHandler().get_settings()
if (
data["authenticate"]
and settings.email_verification
!= Settings.EmailVerificationOptions.ENFORCED
):
response |= {
**generate_session_tokens_for_user(
user,
verified_email_claim=Settings.EmailVerificationOptions.ENFORCED,
include_refresh_token=True,
),
**user_data_registry.get_all_user_data(user, request),
}
return Response(response)
@ -471,6 +511,77 @@ class AccountView(APIView):
return Response(AccountSerializer(user).data)
class SendVerifyEmailView(APIView):
permission_classes = (AllowAny,)
@extend_schema(
tags=["User"],
operation_id="send_verify_email",
description=(
"Sends an email to the user "
"with an email verification link "
"if the user's email is not verified yet."
),
responses={
204: None,
},
)
@transaction.atomic
@validate_body(SendVerifyEmailAddressSerializer)
def post(self, request, data):
"""
Sends a verify email link to the user.
"""
try:
handler = UserHandler()
user = handler.get_active_user(email=data["email"])
action_type_registry.get(SendVerifyEmailAddressActionType.type).do(user)
except (EmailAlreadyVerified, UserNotFound):
# ignore as not to leak existing email addresses
...
return Response(status=204)
class VerifyEmailAddressView(APIView):
permission_classes = (AllowAny,)
@extend_schema(
tags=["User"],
request=VerifyEmailAddressSerializer,
operation_id="verify_email",
description=(
"Passing the correct verification token will "
"confirm that the user's email address belongs to "
"the user."
),
responses={
204: None,
400: get_error_schema(
[
"ERROR_INVALID_VERIFICATION_TOKEN",
"ERROR_EMAIL_ALREADY_VERIFIED",
]
),
},
)
@transaction.atomic
@map_exceptions(
{
InvalidVerificationToken: ERROR_INVALID_VERIFICATION_TOKEN,
EmailAlreadyVerified: ERROR_EMAIL_ALREADY_VERIFIED,
}
)
@validate_body(VerifyEmailAddressSerializer)
def post(self, request, data):
"""
Verifies that the user's email belong to the user.
"""
action_type_registry.get(VerifyEmailAddressActionType.type).do(data["token"])
return Response(status=204)
class ScheduleAccountDeletionView(APIView):
permission_classes = (IsAuthenticated,)

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-18 13:32+0000\n"
"POT-Creation-Date: 2024-04-22 17:45+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"
@ -50,9 +50,9 @@ msgstr ""
msgid "Data source"
msgstr ""
#: src/baserow/contrib/builder/elements/element_types.py:184
#: src/baserow/contrib/builder/elements/element_types.py:189
#: src/baserow/contrib/builder/elements/element_types.py:194
#: src/baserow/contrib/builder/elements/mixins.py:180
#: src/baserow/contrib/builder/elements/mixins.py:185
#: src/baserow/contrib/builder/elements/mixins.py:190
#, python-format
msgid "Column %(count)s"
msgstr ""

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-18 13:32+0000\n"
"POT-Creation-Date: 2024-04-22 17:45+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"
@ -49,7 +49,7 @@ msgid ""
"\"%(airtable_share_id)s\""
msgstr ""
#: src/baserow/contrib/database/application_types.py:231
#: src/baserow/contrib/database/application_types.py:236
msgid "Table"
msgstr ""
@ -62,7 +62,7 @@ msgstr ""
msgid "View \"%(view_name)s\" (%(view_id)s) exported to %(export_type)s"
msgstr ""
#: src/baserow/contrib/database/export/actions.py:82
#: src/baserow/contrib/database/export/actions.py:89
#, python-format
msgid "Table \"%(table_name)s\" (%(table_id)s) exported to %(export_type)s"
msgstr ""
@ -76,43 +76,51 @@ msgstr ""
msgid "Field \"%(field_name)s\" (%(field_id)s) updated"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:332
#: src/baserow/contrib/database/fields/actions.py:339
msgid "Create field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:333
#: src/baserow/contrib/database/fields/actions.py:340
#, python-format
msgid "Field \"%(field_name)s\" (%(field_id)s) created"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:425
#: src/baserow/contrib/database/fields/actions.py:433
msgid "Delete field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:426
#: src/baserow/contrib/database/fields/actions.py:434
#, python-format
msgid "Field \"%(field_name)s\" (%(field_id)s) deleted"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:493
#: src/baserow/contrib/database/fields/actions.py:506
msgid "Duplicate field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:495
#: src/baserow/contrib/database/fields/actions.py:508
#, python-format
msgid ""
"Field \"%(field_name)s\" (%(field_id)s) duplicated (with_data=%(with_data)s) "
"from field \"%(original_field_name)s\" (%(original_field_id)s)"
msgstr ""
#: src/baserow/contrib/database/fields/models.py:358
#: src/baserow/contrib/database/fields/models.py:368
#: src/baserow/contrib/database/fields/models.py:507
msgid "The format of the duration."
msgstr ""
#: src/baserow/contrib/database/fields/notification_types.py:45
#: src/baserow/contrib/database/fields/notification_types.py:53
#, python-format
msgid ""
"%(sender)s assigned you to %(field_name)s in row %(row_id)s in "
"%(sender)s assigned you to %(field_name)s in row %(row_name)s in "
"%(table_name)s."
msgstr ""
#: src/baserow/contrib/database/fields/notification_types.py:181
#, python-format
msgid ""
"%(sender)s mentioned you in %(field_name)s in row %(row_name)s in "
"%(table_name)s."
msgstr ""
@ -127,8 +135,8 @@ msgstr ""
#: src/baserow/contrib/database/plugins.py:72
#: src/baserow/contrib/database/plugins.py:94
#: src/baserow/contrib/database/table/handler.py:376
#: src/baserow/contrib/database/table/handler.py:389
#: src/baserow/contrib/database/table/handler.py:623
#: src/baserow/contrib/database/table/handler.py:636
msgid "Name"
msgstr ""
@ -137,13 +145,13 @@ msgid "Last name"
msgstr ""
#: src/baserow/contrib/database/plugins.py:74
#: src/baserow/contrib/database/table/handler.py:377
#: src/baserow/contrib/database/table/handler.py:624
msgid "Notes"
msgstr ""
#: src/baserow/contrib/database/plugins.py:75
#: src/baserow/contrib/database/plugins.py:96
#: src/baserow/contrib/database/table/handler.py:378
#: src/baserow/contrib/database/table/handler.py:625
msgid "Active"
msgstr ""
@ -180,65 +188,65 @@ msgstr ""
msgid "Row (%(row_id)s) created"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:112
#: src/baserow/contrib/database/rows/actions.py:117
msgid "Create rows"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:112
#: src/baserow/contrib/database/rows/actions.py:117
#, python-format
msgid "Rows (%(row_ids)s) created"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:193
#: src/baserow/contrib/database/rows/actions.py:203
msgid "Import rows"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:193
#: src/baserow/contrib/database/rows/actions.py:203
#, python-format
msgid "Rows (%(row_ids)s) imported"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:273
#: src/baserow/contrib/database/rows/actions.py:284
msgid "Delete row"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:273
#: src/baserow/contrib/database/rows/actions.py:284
#, python-format
msgid "Row (%(row_id)s) deleted"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:334
#: src/baserow/contrib/database/rows/actions.py:350
msgid "Delete rows"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:334
#: src/baserow/contrib/database/rows/actions.py:350
#, python-format
msgid "Rows (%(row_ids)s) deleted"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:476
#: src/baserow/contrib/database/rows/actions.py:497
msgid "Move row"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:476
#: src/baserow/contrib/database/rows/actions.py:497
#, python-format
msgid "Row (%(row_id)s) moved"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:585
#: src/baserow/contrib/database/rows/actions.py:607
msgid "Update row"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:585
#: src/baserow/contrib/database/rows/actions.py:607
#, python-format
msgid "Row (%(row_id)s) updated"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:685
#: src/baserow/contrib/database/rows/actions.py:707
msgid "Update rows"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:685
#: src/baserow/contrib/database/rows/actions.py:707
#, python-format
msgid "Rows (%(row_ids)s) updated"
msgstr ""
@ -252,50 +260,50 @@ msgstr ""
msgid "Table \"%(table_name)s\" (%(table_id)s) created"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:100
#: src/baserow/contrib/database/table/actions.py:104
msgid "Delete table"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:101
#: src/baserow/contrib/database/table/actions.py:105
#, python-format
msgid "Table \"%(table_name)s\" (%(table_id)s) deleted"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:152
#: src/baserow/contrib/database/table/actions.py:160
msgid "Order tables"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:153
#: src/baserow/contrib/database/table/actions.py:161
msgid "Tables order changed"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:213
#: src/baserow/contrib/database/table/actions.py:224
msgid "Update table"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:215
#: src/baserow/contrib/database/table/actions.py:226
#, python-format
msgid ""
"Table (%(table_id)s) name changed from \"%(original_table_name)s\" to "
"\"%(table_name)s\""
msgstr ""
#: src/baserow/contrib/database/table/actions.py:281
#: src/baserow/contrib/database/table/actions.py:296
msgid "Duplicate table"
msgstr ""
#: src/baserow/contrib/database/table/actions.py:283
#: src/baserow/contrib/database/table/actions.py:298
#, python-format
msgid ""
"Table \"%(table_name)s\" (%(table_id)s) duplicated from "
"\"%(original_table_name)s\" (%(original_table_id)s) "
msgstr ""
#: src/baserow/contrib/database/table/handler.py:280
#: src/baserow/contrib/database/table/handler.py:527
msgid "Grid"
msgstr ""
#: src/baserow/contrib/database/table/handler.py:338
#: src/baserow/contrib/database/table/handler.py:585
#, python-format
msgid "Field %d"
msgstr ""
@ -310,42 +318,42 @@ msgid ""
"A Database Token with name \"%(token_name)s\" (%(token_id)s) has been created"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:54
#: src/baserow/contrib/database/tokens/actions.py:58
msgid "Update DB token name"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:56
#: src/baserow/contrib/database/tokens/actions.py:60
#, python-format
msgid ""
"The Database Token (%(token_name)s) name changed from "
"\"%(original_token_name)s\" to \"%(token_name)s\""
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:94
#: src/baserow/contrib/database/tokens/actions.py:102
msgid "Update DB token permissions"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:96
#: src/baserow/contrib/database/tokens/actions.py:104
#, python-format
msgid ""
"The Database Token \"%(token_name)s\" (%(token_id)s) permissions has been "
"updated"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:149
#: src/baserow/contrib/database/tokens/actions.py:163
msgid "Rotate DB token key"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:150
#: src/baserow/contrib/database/tokens/actions.py:164
#, python-format
msgid "The Database Token \"%(token_name)s\" (%(token_id)s) has been rotated"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:182
#: src/baserow/contrib/database/tokens/actions.py:200
msgid "Delete DB token"
msgstr ""
#: src/baserow/contrib/database/tokens/actions.py:183
#: src/baserow/contrib/database/tokens/actions.py:201
#, python-format
msgid "The Database Token \"%(token_name)s\" (%(token_id)s) has been deleted"
msgstr ""
@ -359,198 +367,198 @@ msgstr ""
msgid "View filter created on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:143
#: src/baserow/contrib/database/views/actions.py:152
msgid "Update a view filter"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:144
#: src/baserow/contrib/database/views/actions.py:153
#, python-format
msgid "View filter updated on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:265
#: src/baserow/contrib/database/views/actions.py:284
msgid "Delete a view filter"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:266
#: src/baserow/contrib/database/views/actions.py:285
#, python-format
msgid "View filter deleted from field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:366
#: src/baserow/contrib/database/views/actions.py:394
msgid "Create a view filter group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:367
#: src/baserow/contrib/database/views/actions.py:395
msgid "View filter group created"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:443
#: src/baserow/contrib/database/views/actions.py:478
msgid "Update a view filter group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:444
#: src/baserow/contrib/database/views/actions.py:479
#, python-format
msgid "View filter group updated to \"%(filter_type)s\""
msgstr ""
#: src/baserow/contrib/database/views/actions.py:536
#: src/baserow/contrib/database/views/actions.py:579
msgid "Delete a view filter group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:537
#: src/baserow/contrib/database/views/actions.py:580
msgid "View filter group deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:649
#: src/baserow/contrib/database/views/actions.py:699
msgid "Create a view sort"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:650
#: src/baserow/contrib/database/views/actions.py:700
#, python-format
msgid "View sorted on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:726
#: src/baserow/contrib/database/views/actions.py:784
msgid "Update a view sort"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:727
#: src/baserow/contrib/database/views/actions.py:785
#, python-format
msgid "View sort updated on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:825
#: src/baserow/contrib/database/views/actions.py:893
msgid "Delete a view sort"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:826
#: src/baserow/contrib/database/views/actions.py:894
#, python-format
msgid "View sort deleted from field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:903
#: src/baserow/contrib/database/views/actions.py:979
msgid "Order views"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:903
#: src/baserow/contrib/database/views/actions.py:979
msgid "Views order changed"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:970
#: src/baserow/contrib/database/views/actions.py:1050
msgid "Update view field options"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:971
#: src/baserow/contrib/database/views/actions.py:1051
msgid "ViewFieldOptions updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1066
#: src/baserow/contrib/database/views/actions.py:1152
msgid "View slug URL updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1067
#: src/baserow/contrib/database/views/actions.py:1153
msgid "View changed public slug URL"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1136
#: src/baserow/contrib/database/views/actions.py:1226
msgid "Update view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1137
#: src/baserow/contrib/database/views/actions.py:1227
#, python-format
msgid "View \"%(view_name)s\" (%(view_id)s) updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1213
#: src/baserow/contrib/database/views/actions.py:1308
msgid "Create view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1214
#: src/baserow/contrib/database/views/actions.py:1309
#, python-format
msgid "View \"%(view_name)s\" (%(view_id)s) created"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1282
#: src/baserow/contrib/database/views/actions.py:1383
msgid "Duplicate view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1284
#: src/baserow/contrib/database/views/actions.py:1385
#, python-format
msgid ""
"View \"%(view_name)s\" (%(view_id)s) duplicated from view "
"\"%(original_view_name)s\" (%(original_view_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1352
#: src/baserow/contrib/database/views/actions.py:1459
msgid "Delete view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1353
#: src/baserow/contrib/database/views/actions.py:1460
#, python-format
msgid "View \"%(view_name)s\" (%(view_id)s) deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1410
#: src/baserow/contrib/database/views/actions.py:1522
msgid "Create decoration"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1411
#: src/baserow/contrib/database/views/actions.py:1523
#, python-format
msgid "View decoration %(decorator_id)s created"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1506
#: src/baserow/contrib/database/views/actions.py:1627
msgid "Update decoration"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1507
#: src/baserow/contrib/database/views/actions.py:1628
#, python-format
msgid "View decoration %(decorator_id)s updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1631
#: src/baserow/contrib/database/views/actions.py:1765
msgid "Delete decoration"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1632
#: src/baserow/contrib/database/views/actions.py:1766
#, python-format
msgid "View decoration %(decorator_id)s deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1717
#: src/baserow/contrib/database/views/actions.py:1862
msgid "Create a view group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1718
#: src/baserow/contrib/database/views/actions.py:1863
#, python-format
msgid "View grouped on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1809
#: src/baserow/contrib/database/views/actions.py:1963
msgid "Update a view group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1810
#: src/baserow/contrib/database/views/actions.py:1964
#, python-format
msgid "View group by updated on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1925
#: src/baserow/contrib/database/views/actions.py:2092
msgid "Delete a view group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1926
#: src/baserow/contrib/database/views/actions.py:2093
#, python-format
msgid "View group by deleted from field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/notification_types.py:83
#: src/baserow/contrib/database/views/notification_types.py:84
#, python-format
msgid "%(form_name)s has been submitted in %(table_name)s"
msgstr ""
#: src/baserow/contrib/database/views/notification_types.py:100
#: src/baserow/contrib/database/views/notification_types.py:101
#, python-format
msgid "and 1 more field"
msgid_plural "and %(count)s more fields"
@ -568,22 +576,22 @@ msgid ""
"to %(webhook_url)s\" created"
msgstr ""
#: src/baserow/contrib/database/webhooks/actions.py:82
#: src/baserow/contrib/database/webhooks/actions.py:88
msgid "Delete Webhook"
msgstr ""
#: src/baserow/contrib/database/webhooks/actions.py:84
#: src/baserow/contrib/database/webhooks/actions.py:90
#, python-format
msgid ""
"Webhook \"%(webhook_name)s\" (%(webhook_id)s) as %(webhook_request_method)s "
"to %(webhook_url)s\" deleted"
msgstr ""
#: src/baserow/contrib/database/webhooks/actions.py:140
#: src/baserow/contrib/database/webhooks/actions.py:152
msgid "Update Webhook"
msgstr ""
#: src/baserow/contrib/database/webhooks/actions.py:142
#: src/baserow/contrib/database/webhooks/actions.py:154
#, python-format
msgid ""
"Webhook \"%(webhook_name)s\" (%(webhook_id)s) as %(webhook_request_method)s "

View file

@ -231,8 +231,10 @@ class CoreConfig(AppConfig):
ResetUserPasswordActionType,
ScheduleUserDeletionActionType,
SendResetUserPasswordActionType,
SendVerifyEmailAddressActionType,
SignInUserActionType,
UpdateUserActionType,
VerifyEmailAddressActionType,
)
action_type_registry.register(CreateUserActionType())
@ -243,6 +245,8 @@ class CoreConfig(AppConfig):
action_type_registry.register(ChangeUserPasswordActionType())
action_type_registry.register(SendResetUserPasswordActionType())
action_type_registry.register(ResetUserPasswordActionType())
action_type_registry.register(SendVerifyEmailAddressActionType())
action_type_registry.register(VerifyEmailAddressActionType())
from baserow.core.action.scopes import (
ApplicationActionScopeType,

View file

@ -6,6 +6,10 @@ class AuthProviderDisabled(Exception):
"""Raised when it is not possible to use a particular auth provider."""
class EmailVerificationRequired(Exception):
"""Raised when the user's email has not been verified yet."""
class DifferentAuthProvider(Exception):
"""
Raised when logging in an existing user that should not

View file

@ -86,6 +86,22 @@ class BaseEmailMessage(EmailMultiAlternatives):
transaction.on_commit(lambda: s.send(fail_silently))
class EmailPendingVerificationEmail(BaseEmailMessage):
template_name = "baserow/core/user/email_pending_verification.html"
def __init__(self, confirm_url, *args, **kwargs):
self.confirm_url = confirm_url
super().__init__(*args, **kwargs)
def get_subject(self):
return _("Please confirm email")
def get_context(self):
context = super().get_context()
context.update(confirm_url=self.confirm_url)
return context
class WorkspaceInvitationEmail(BaseEmailMessage):
template_name = "baserow/core/workspace_invitation.html"

View file

@ -172,6 +172,7 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
"track_workspace_usage",
"show_baserow_help_request",
"co_branding_logo",
"email_verification",
],
settings_instance,
)

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-18 13:32+0000\n"
"POT-Creation-Date: 2024-04-22 17:45+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"
@ -23,88 +23,88 @@ msgstr ""
msgid "in group \"%(group_name)s\" (%(group_id)s)."
msgstr ""
#: src/baserow/core/actions.py:36
#: src/baserow/core/actions.py:35
msgid "Delete group"
msgstr ""
#: src/baserow/core/actions.py:37
#: src/baserow/core/actions.py:36
#, python-format
msgid "Group \"%(group_name)s\" (%(group_id)s) deleted."
msgstr ""
#: src/baserow/core/actions.py:97
#: src/baserow/core/actions.py:99
msgid "Create group"
msgstr ""
#: src/baserow/core/actions.py:98
#: src/baserow/core/actions.py:100
#, python-format
msgid "Group \"%(group_name)s\" (%(group_id)s) created."
msgstr ""
#: src/baserow/core/actions.py:156
#: src/baserow/core/actions.py:161
msgid "Update group"
msgstr ""
#: src/baserow/core/actions.py:158
#: src/baserow/core/actions.py:163
#, python-format
msgid ""
"Group (%(group_id)s) name changed from \"%(original_group_name)s\" to "
"\"%(group_name)s.\""
msgstr ""
#: src/baserow/core/actions.py:237
#: src/baserow/core/actions.py:245
msgid "Order groups"
msgstr ""
#: src/baserow/core/actions.py:238
#: src/baserow/core/actions.py:246
msgid "Groups order changed."
msgstr ""
#: src/baserow/core/actions.py:297
#: src/baserow/core/actions.py:305
msgid "Order applications"
msgstr ""
#: src/baserow/core/actions.py:297
#: src/baserow/core/actions.py:305
msgid "Applications reordered"
msgstr ""
#: src/baserow/core/actions.py:360
#: src/baserow/core/actions.py:371
msgid "Create application"
msgstr ""
#: src/baserow/core/actions.py:361
#: src/baserow/core/actions.py:372
#, python-format
msgid ""
"\"%(application_name)s\" (%(application_id)s) %(application_type)s created"
msgstr ""
#: src/baserow/core/actions.py:442
#: src/baserow/core/actions.py:453
msgid "Delete application"
msgstr ""
#: src/baserow/core/actions.py:444
#: src/baserow/core/actions.py:455
#, python-format
msgid ""
"Application \"%(application_name)s\" (%(application_id)s) of type "
"%(application_type)s deleted"
msgstr ""
#: src/baserow/core/actions.py:506
#: src/baserow/core/actions.py:522
msgid "Update application"
msgstr ""
#: src/baserow/core/actions.py:508
#: src/baserow/core/actions.py:524
#, python-format
msgid ""
"Application (%(application_id)s) of type %(application_type)s renamed from "
"\"%(original_application_name)s\" to \"%(application_name)s\""
msgstr ""
#: src/baserow/core/actions.py:579
#: src/baserow/core/actions.py:606
msgid "Duplicate application"
msgstr ""
#: src/baserow/core/actions.py:581
#: src/baserow/core/actions.py:608
#, python-format
msgid ""
"Application \"%(application_name)s\" (%(application_id)s) of type "
@ -112,66 +112,66 @@ msgid ""
"\" (%(original_application_id)s)"
msgstr ""
#: src/baserow/core/actions.py:658
#: src/baserow/core/actions.py:691
msgid "Install template"
msgstr ""
#: src/baserow/core/actions.py:660
#: src/baserow/core/actions.py:693
#, python-format
msgid ""
"Template \"%(template_name)s\" (%(template_id)s) installed into application "
"IDs %(installed_application_ids)s"
msgstr ""
#: src/baserow/core/actions.py:738
#: src/baserow/core/actions.py:776
msgid "Create group invitation"
msgstr ""
#: src/baserow/core/actions.py:740
#: src/baserow/core/actions.py:778
#, python-format
msgid ""
"Group invitation created for \"%(email)s\" to join \"%(group_name)s"
"\" (%(group_id)s) as %(permissions)s."
msgstr ""
#: src/baserow/core/actions.py:791
#: src/baserow/core/actions.py:833
msgid "Delete group invitation"
msgstr ""
#: src/baserow/core/actions.py:793
#: src/baserow/core/actions.py:835
#, python-format
msgid ""
"Group invitation (%(invitation_id)s) deleted for \"%(email)s\" to join "
"\"%(group_name)s\" (%(group_id)s) as %(permissions)s."
msgstr ""
#: src/baserow/core/actions.py:846
#: src/baserow/core/actions.py:893
msgid "Accept group invitation"
msgstr ""
#: src/baserow/core/actions.py:848
#: src/baserow/core/actions.py:895
#, python-format
msgid ""
"Invitation (%(invitation_id)s) sent by \"%(sender)s\" to join "
"\"%(group_name)s\" (%(group_id)s) as %(permissions)s was accepted."
msgstr ""
#: src/baserow/core/actions.py:901
#: src/baserow/core/actions.py:953
msgid "Reject group invitation"
msgstr ""
#: src/baserow/core/actions.py:903
#: src/baserow/core/actions.py:955
#, python-format
msgid ""
"Invitation (%(invitation_id)s) sent by \"%(sender)s\" to join "
"\"%(group_name)s\" (%(group_id)s) as %(permissions)s was rejected."
msgstr ""
#: src/baserow/core/actions.py:956
#: src/baserow/core/actions.py:1013
msgid "Update group invitation permissions"
msgstr ""
#: src/baserow/core/actions.py:958
#: src/baserow/core/actions.py:1015
#, python-format
msgid ""
"Invitation (%(invitation_id)s) permissions changed from "
@ -179,35 +179,40 @@ msgid ""
"\"%(group_name)s\" (%(group_id)s)."
msgstr ""
#: src/baserow/core/actions.py:1015
#: src/baserow/core/actions.py:1078
msgid "Leave group"
msgstr ""
#: src/baserow/core/actions.py:1016
#: src/baserow/core/actions.py:1079
#, python-format
msgid "Group \"%(group_name)s\" (%(group_id)s) left."
msgstr ""
#: src/baserow/core/emails.py:103
#: src/baserow/core/emails.py:97
#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:176
msgid "Please confirm email"
msgstr ""
#: src/baserow/core/emails.py:115
#, python-format
msgid "%(by)s invited you to %(workspace_name)s - Baserow"
msgstr ""
#: src/baserow/core/emails.py:136
#: src/baserow/core/emails.py:148
msgid "You have 1 new notification - Baserow"
msgstr ""
#: src/baserow/core/emails.py:138
#: src/baserow/core/emails.py:150
#, python-format
msgid "You have %(count)d new notifications - Baserow"
msgstr ""
#: src/baserow/core/notification_types.py:94
#: src/baserow/core/notification_types.py:92
#, python-format
msgid "%(user)s accepted your invitation to collaborate to %(workspace_name)s."
msgstr ""
#: src/baserow/core/notification_types.py:135
#: src/baserow/core/notification_types.py:133
#, python-format
msgid "%(user)s rejected your invitation to collaborate to %(workspace_name)s."
msgstr ""
@ -223,11 +228,11 @@ msgid ""
"\"%(application_name)s\" (%(application_id)s)."
msgstr ""
#: src/baserow/core/snapshots/actions.py:68
#: src/baserow/core/snapshots/actions.py:72
msgid "Restore Snapshot"
msgstr ""
#: src/baserow/core/snapshots/actions.py:70
#: src/baserow/core/snapshots/actions.py:74
#, python-format
msgid ""
"Snapshot \"%(snapshot_name)s\" (%(snapshot_id)s) restored from application "
@ -235,119 +240,116 @@ msgid ""
"application \"%(application_name)s\" (%(application_id)s)."
msgstr ""
#: src/baserow/core/snapshots/actions.py:124
#: src/baserow/core/snapshots/actions.py:133
msgid "Delete Snapshot"
msgstr ""
#: src/baserow/core/snapshots/actions.py:126
#: src/baserow/core/snapshots/actions.py:135
#, python-format
msgid ""
"Snapshot \"%(snapshot_name)s\" (%(snapshot_id)s) deleted for application "
"\"%(application_name)s\" (%(application_id)s)."
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:144
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:144
msgid "Invitation"
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:149
#, python-format
msgid ""
"<strong>%(first_name)s</strong> has invited you to collaborate on <strong>"
"%(group_name)s</strong>."
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:163
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:167
msgid "Accept invitation"
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:177
#: src/baserow/core/templates/baserow/core/notifications_summary.html:212
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:154
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:154
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:159
#: src/baserow/core/templates/baserow/core/user/reset_password.html:177
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:181
msgid ""
"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."
msgstr ""
#: src/baserow/core/templates/baserow/core/notifications_summary.html:146
#: src/baserow/core/templates/baserow/core/notifications_summary.html:176
#, python-format
msgid "You have %(counter)s new notification"
msgid_plural "You have %(counter)s new notifications"
msgstr[0] ""
msgstr[1] ""
#: src/baserow/core/templates/baserow/core/notifications_summary.html:187
#: src/baserow/core/templates/baserow/core/notifications_summary.html:228
#, python-format
msgid "Plus %(counter)s more notification."
msgid_plural "Plus %(counter)s more notifications."
msgstr[0] ""
msgstr[1] ""
#: src/baserow/core/templates/baserow/core/notifications_summary.html:198
#: src/baserow/core/templates/baserow/core/notifications_summary.html:239
msgid "View in Baserow"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:144
#: src/baserow/core/templates/baserow/core/notifications_summary.html:253
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:186
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:186
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:191
#: src/baserow/core/templates/baserow/core/user/reset_password.html:209
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:213
msgid ""
"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."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:176
msgid "Account permanently deleted"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:149
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:181
#, python-format
msgid ""
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
"Your account (%(username)s) on Baserow (%(baserow_embedded_share_hostname)s) "
"has been permanently deleted."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:144
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:176
msgid "Account deletion cancelled"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:149
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:181
#, python-format
msgid ""
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
"Your account (%(username)s) on Baserow (%(baserow_embedded_share_hostname)s) "
"was pending deletion, but you've logged in so this operation has been "
"cancelled."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:144
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:176
msgid "Account pending deletion"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:149
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:181
#, python-format
msgid ""
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
"Your account (%(username)s) on Baserow (%(baserow_embedded_share_hostname)s) "
"will be permanently deleted in %(days_left)s days."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:154
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:186
msgid ""
"If you've changed your mind and want to cancel your account deletion, you "
"just have to login again."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/reset_password.html:144
#: src/baserow/core/templates/baserow/core/user/reset_password.html:163
#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:181
msgid "Thank you for using Baserow"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:186
msgid ""
"To keep your account secure, please take a moment to verify your email by "
"clicking the button below. Your email address will be used to assist you in "
"changing your Baserow password should you ever need to in the future."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:195
msgid "Confirm"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/reset_password.html:176
#: src/baserow/core/templates/baserow/core/user/reset_password.html:195
msgid "Reset password"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/reset_password.html:149
#: src/baserow/core/templates/baserow/core/user/reset_password.html:181
#, python-format
msgid ""
"A password reset was requested for your account (%(username)s) on Baserow "
"(%(public_web_frontend_hostname)s). If you did not authorize this, you may "
"simply ignore this email."
"(%(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/reset_password.html:154
#: src/baserow/core/templates/baserow/core/user/reset_password.html:186
#, python-format
msgid ""
"To continue with your password reset, simply click the button below, and you "
@ -355,13 +357,21 @@ msgid ""
"hours."
msgstr ""
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:149
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:176
msgid "Invitation"
msgstr ""
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:181
#, python-format
msgid ""
"<strong>%(first_name)s</strong> has invited you to collaborate on <strong>"
"%(workspace_name)s</strong>."
msgstr ""
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:199
msgid "Accept invitation"
msgstr ""
#: src/baserow/core/trash/actions.py:20
msgid "Empty trash"
msgstr ""
@ -373,18 +383,18 @@ msgid ""
"emptied"
msgstr ""
#: src/baserow/core/trash/actions.py:79
#: src/baserow/core/trash/actions.py:83
#, python-format
msgid ""
"Trash for workspace \"%(workspace_name)s\" (%(workspace_id)s) has been "
"emptied."
msgstr ""
#: src/baserow/core/trash/actions.py:90
#: src/baserow/core/trash/actions.py:94
msgid "Restore from trash"
msgstr ""
#: src/baserow/core/trash/actions.py:91
#: src/baserow/core/trash/actions.py:95
#, python-format
msgid ""
"Item of type \"%(item_type)s\" (%(item_id)s) has been restored from trash"
@ -402,75 +412,93 @@ msgid ""
"%(with_invitation_token)s)"
msgstr ""
#: src/baserow/core/user/actions.py:110
#: src/baserow/core/user/actions.py:117
msgid "Update User"
msgstr ""
#: src/baserow/core/user/actions.py:111
#: src/baserow/core/user/actions.py:118
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) updated"
msgstr ""
#: src/baserow/core/user/actions.py:163
#: src/baserow/core/user/actions.py:174
msgid "Schedule user deletion"
msgstr ""
#: src/baserow/core/user/actions.py:165
#: src/baserow/core/user/actions.py:176
#, python-format
msgid ""
"User \"%(user_email)s\" (%(user_id)s) scheduled to be deleted after grace "
"time"
msgstr ""
#: src/baserow/core/user/actions.py:196
#: src/baserow/core/user/actions.py:210
msgid "Cancel user deletion"
msgstr ""
#: src/baserow/core/user/actions.py:198
#: src/baserow/core/user/actions.py:212
#, python-format
msgid ""
"User \"%(user_email)s\" (%(user_id)s) logged in cancelling the deletion "
"process"
msgstr ""
#: src/baserow/core/user/actions.py:229
#: src/baserow/core/user/actions.py:246
msgid "Sign In User"
msgstr ""
#: src/baserow/core/user/actions.py:231
#: src/baserow/core/user/actions.py:248
#, python-format
msgid ""
"User \"%(user_email)s\" (%(user_id)s) signed in via \"%(auth_provider_type)s"
"\" (%(auth_provider_id)s) auth provider"
msgstr ""
#: src/baserow/core/user/actions.py:283
#: src/baserow/core/user/actions.py:305
msgid "Send reset user password"
msgstr ""
#: src/baserow/core/user/actions.py:284
#: src/baserow/core/user/actions.py:306
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) requested to reset password"
msgstr ""
#: src/baserow/core/user/actions.py:314
#: src/baserow/core/user/actions.py:339
msgid "Change user password"
msgstr ""
#: src/baserow/core/user/actions.py:315
#: src/baserow/core/user/actions.py:340
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) changed password"
msgstr ""
#: src/baserow/core/user/actions.py:351
#: src/baserow/core/user/actions.py:379
msgid "Reset user password"
msgstr ""
#: src/baserow/core/user/actions.py:352
#: src/baserow/core/user/actions.py:380
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) reset password"
msgstr ""
#: src/baserow/core/user/actions.py:416
msgid "Send verify email"
msgstr ""
#: src/baserow/core/user/actions.py:417
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) requested to verify email"
msgstr ""
#: src/baserow/core/user/actions.py:450
msgid "Verify email"
msgstr ""
#: src/baserow/core/user/actions.py:451
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) verify email"
msgstr ""
#: src/baserow/core/user/emails.py:16
msgid "Reset password - Baserow"
msgstr ""
@ -487,7 +515,7 @@ msgstr ""
msgid "Account deletion cancelled - Baserow"
msgstr ""
#: src/baserow/core/user/handler.py:248
#: src/baserow/core/user/handler.py:252
#, python-format
msgid "%(name)s's workspace"
msgstr ""

View file

@ -0,0 +1,32 @@
# Generated by Django 4.1.13 on 2024-04-03 09:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0085_workspace_generative_ai_models_settings"),
]
operations = [
migrations.AddField(
model_name="settings",
name="email_verification",
field=models.TextField(
choices=[
("no_verification", "no_verification"),
("recommended", "recommended"),
("enforced", "enforced"),
],
default="no_verification",
help_text="Controls whether user email addresses have to be verified.",
max_length=16,
null=True,
),
),
migrations.AddField(
model_name="userprofile",
name="email_verified",
field=models.BooleanField(default=False, null=True),
),
]

View file

@ -80,6 +80,13 @@ class Settings(models.Model):
change. This table can only contain a single row.
"""
# Keep these in sync with the web-frontend options in
# web-frontend/modules/core/enums.js
class EmailVerificationOptions(models.TextChoices):
NO_VERIFICATION = "no_verification", "no_verification"
RECOMMENDED = "recommended", "recommended"
ENFORCED = "enforced", "enforced"
instance_id = models.SlugField(default=secrets.token_urlsafe)
allow_new_signups = models.BooleanField(
default=True,
@ -131,6 +138,14 @@ class Settings(models.Model):
related_name="+",
help_text="Co-branding logo that's placed next to the Baserow logo (176x29).",
)
# TODO Remove null=True in a future release.
email_verification = models.TextField(
max_length=16,
null=True,
choices=EmailVerificationOptions.choices,
default=EmailVerificationOptions.NO_VERIFICATION,
help_text="Controls whether user email addresses have to be verified.",
)
class UserProfile(models.Model):
@ -188,6 +203,8 @@ class UserProfile(models.Model):
default=None,
help_text="Timestamp when the user changed their password.",
)
# TODO Remove null=True in a future release.
email_verified = models.BooleanField(null=True, default=False)
def iat_before_last_password_change(self, iat: int) -> bool:
"""

View file

@ -0,0 +1,220 @@
{% load i18n %}
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Inter:400,600);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-px-190 {
width: 190px !important;
max-width: 190px;
}
.mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-px-190 {
width: 190px !important;
max-width: 190px;
}
.moz-text-html .mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;">
<div style="">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:left;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:190px;" ><![endif]-->
<div class="mj-column-px-190 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:140px;">
<a href="{{ baserow_embedded_share_url }}" target="_blank">
<img height="auto" src="{{ logo_url }}" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" />
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-left:0;word-break:break-word;">
<div style="font-family:Inter,sans-serif;font-size:13px;line-height:170%;text-align:left;color:#070810;">{{ logo_additional_text }}</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Inter,sans-serif;font-size:22px;font-weight:600;line-height:1;text-align:left;color:#070810;">{% trans "Please confirm email" %}</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Inter,sans-serif;font-size:13px;line-height:170%;text-align:left;color:#070810;">{% blocktrans trimmed %} Thank you for using Baserow {% endblocktrans %}</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Inter,sans-serif;font-size:13px;line-height:170%;text-align:left;color:#070810;">{% blocktrans trimmed %} To keep your account secure, please take a moment to verify your email by clicking the button below. Your email address will be used to assist you in changing your Baserow password should you ever need to in the future. {% endblocktrans %}</div>
</td>
</tr>
<tr>
<td align="left" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tbody>
<tr>
<td align="center" bgcolor="#5190ef" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:12px 30px;background:#5190ef;" valign="middle">
<a href="{{ confirm_url }}" style="display:inline-block;background:#5190ef;color:#ffffff;font-family:Inter,sans-serif;font-size:15px;font-weight:600;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:12px 30px;mso-padding-alt:0px;border-radius:4px;" target="_blank"> {% trans "Confirm" %} </a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Inter,sans-serif;font-size:12px;line-height:1;text-align:left;color:#9c9c9f;">{{ confirm_url }}</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View file

@ -0,0 +1,23 @@
<% layout("../../base.layout.eta") %>
<mj-section>
<mj-column>
<mj-text mj-class="title">{% trans "Please confirm email" %}</mj-text>
<mj-text mj-class="text">
{% blocktrans trimmed %}
Thank you for using Baserow
{% endblocktrans %}
</mj-text>
<mj-text mj-class="text">
{% blocktrans trimmed %}
To keep your account secure, please take a moment to verify your email by clicking the button below. Your email address will be used to assist you in changing your Baserow password should you ever need to in the future.
{% endblocktrans %}
</mj-text>
<mj-button mj-class="button mt-20" href="{{ confirm_url }}">
{% trans "Confirm" %}
</mj-button>
<mj-text mj-class="button-url">
{{ confirm_url }}
</mj-text>
</mj-column>
</mj-section>

View file

@ -12,7 +12,7 @@ from baserow.core.action.registries import (
from baserow.core.action.scopes import RootActionScopeType
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.auth_provider.models import AuthProviderModel
from baserow.core.models import Template
from baserow.core.models import Template, User
from baserow.core.registries import auth_provider_type_registry
from baserow.core.user.handler import UserHandler
@ -408,3 +408,75 @@ class ResetUserPasswordActionType(ActionType):
@classmethod
def scope(cls) -> ActionScopeStr:
return RootActionScopeType.value()
class SendVerifyEmailAddressActionType(ActionType):
type = "send_verify_email"
description = ActionTypeDescription(
_("Send verify email"),
_('User "%(user_email)s" (%(user_id)s) requested to verify email'),
)
analytics_params = [
"user_id",
]
@dataclasses.dataclass
class Params:
user_id: int
user_email: str
@classmethod
def do(cls, user: User):
"""
Sends an email verification email to the user.
"""
UserHandler().send_email_pending_verification(user)
cls.register_action(
user=user, params=cls.Params(user.id, user.email), scope=cls.scope()
)
return user
@classmethod
def scope(cls) -> ActionScopeStr:
return RootActionScopeType.value()
class VerifyEmailAddressActionType(ActionType):
type = "verify_email"
description = ActionTypeDescription(
_("Verify email"),
_('User "%(user_email)s" (%(user_id)s) verify email'),
)
analytics_params = [
"user_id",
]
@dataclasses.dataclass
class Params:
user_id: int
user_email: str
@classmethod
def do(cls, verification_token: str):
"""
Confirm that the associated email address
belongs to the user.
:param verification_token: The secret token to
verify that the email belongs to the user.
"""
user = UserHandler().verify_email_address(verification_token)
cls.register_action(
user=user, params=cls.Params(user.id, user.email), scope=cls.scope()
)
return user
@classmethod
def scope(cls) -> ActionScopeStr:
return RootActionScopeType.value()

View file

@ -6,6 +6,14 @@ class UserAlreadyExist(Exception):
"""Raised when a user could not be created because the email already exists."""
class InvalidVerificationToken(Exception):
"""Raised when the provided token is invalid."""
class EmailAlreadyVerified(Exception):
"""Raised when the user's email is verified already."""
class PasswordDoesNotMatchValidation(Exception):
"""Raised when the provided password does not match validation."""

View file

@ -14,11 +14,12 @@ from django.db.utils import IntegrityError
from django.utils import timezone, translation
from django.utils.translation import gettext as _
from itsdangerous import URLSafeTimedSerializer
from itsdangerous import URLSafeSerializer, URLSafeTimedSerializer
from opentelemetry import trace
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.auth_provider.models import AuthProviderModel
from baserow.core.emails import EmailPendingVerificationEmail
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed,
WorkspaceInvitationEmailMismatch,
@ -26,6 +27,7 @@ from baserow.core.exceptions import (
from baserow.core.handler import CoreHandler
from baserow.core.models import (
BlacklistedToken,
Settings,
Template,
UserLogEntry,
UserProfile,
@ -53,7 +55,9 @@ from .emails import (
from .exceptions import (
DeactivatedUserException,
DisabledSignupError,
EmailAlreadyVerified,
InvalidPassword,
InvalidVerificationToken,
PasswordDoesNotMatchValidation,
RefreshTokenAlreadyBlacklisted,
ResetPasswordDisabledError,
@ -238,6 +242,9 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
workspace_user = core_handler.accept_workspace_invitation(
user, workspace_invitation
)
profile = user.profile
profile.email_verified = True
profile.save()
# If we still don't have a `WorkspaceUser`, which will be because we weren't
# invited to a workspace, and `allow_global_workspace_creation` is enabled,
@ -264,6 +271,17 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
auth_provider = PasswordProviderHandler.get()
auth_provider.user_signed_in(user)
settings = CoreHandler().get_settings()
email_verification_send_email_if = [
Settings.EmailVerificationOptions.RECOMMENDED,
Settings.EmailVerificationOptions.ENFORCED,
]
if (
settings.email_verification in email_verification_send_email_if
and user.profile.email_verified is False
):
UserHandler().send_email_pending_verification(user)
return user
def update_user(
@ -384,6 +402,7 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
# Update the last password change timestamp to invalidate old authentication
# tokens.
user.profile.last_password_change = timezone.now()
user.profile.email_verified = True
user.profile.save()
user_password_changed.send(
@ -642,3 +661,91 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
hashed_token = generate_hash(refresh_token)
return BlacklistedToken.objects.filter(hashed_token=hashed_token).exists()
def _get_email_verification_signer(self) -> URLSafeSerializer:
return URLSafeSerializer(settings.SECRET_KEY, "verify-email")
def create_email_verification_token(self, user: User) -> str:
"""
Creates email verification token for the provided user
based on the current user's email address.
:param user: The user for which the token should be generated.
"""
signer = self._get_email_verification_signer()
expires_at = datetime.datetime.now(datetime.timezone.utc) + timedelta(days=1)
return signer.dumps(
{
"user_id": user.id,
"email": user.email,
"expires_at": expires_at.isoformat(),
}
)
def verify_email_address(self, token: str) -> User:
"""
Marks the user's email address as verified if the
provided token is a valid token that confirms the user's
email address.
:param token: The verification token.
:raises InvalidVerificationToken: Raised when the token is
expired, the user doesn't exist, or the user's email
address doesn't match the one the token was issued for.
:raises EmailAlreadyVerified: Raised when the user's
email is verified already.
"""
signer = self._get_email_verification_signer()
token_data = signer.loads(token)
if datetime.datetime.fromisoformat(
token_data["expires_at"]
) < datetime.datetime.now(tz=timezone.utc):
raise InvalidVerificationToken()
try:
user = self.get_active_user(user_id=token_data["user_id"])
except UserNotFound as ex:
raise InvalidVerificationToken() from ex
if user.profile.email_verified:
raise EmailAlreadyVerified()
if user.email != token_data["email"]:
raise InvalidVerificationToken()
user_profile = user.profile
user_profile.email_verified = True
user_profile.save()
return user
def send_email_pending_verification(self, user: User):
"""
Sends out a pending verification email to the user.
:user: User that should receive the verification email.
:raises EmailAlreadyVerified: Raised when the user's
email is verified already.
"""
if user.profile.email_verified:
raise EmailAlreadyVerified()
token = self.create_email_verification_token(user)
base_url = (
settings.BASEROW_EMBEDDED_SHARE_URL or settings.PUBLIC_WEB_FRONTEND_URL
)
if not base_url.endswith("/"):
base_url += "/"
confirm_url = urljoin(base_url, f"verify-email-address/{token}")
with translation.override(user.profile.language):
email = EmailPendingVerificationEmail(
to=[user.email],
confirm_url=confirm_url,
)
email.send(fail_silently=False)

View file

@ -21,18 +21,26 @@ def normalize_email_address(email):
def generate_session_tokens_for_user(
user: AbstractUser, include_refresh_token: bool = False
user: AbstractUser,
include_refresh_token: bool = False,
verified_email_claim: Optional[str] = None,
) -> Dict[str, str]:
"""
Generates a new access and refresh token (if requested) for the given user.
:param user: The user for which the tokens must be generated.
:param include_refresh_token: Whether or not a refresh token must be included.
:param verified_email_claim: Optionally stores which authentication
method was used.
:return: A dictionary with the access and refresh token.
"""
access_token = AccessToken.for_user(user)
refresh_token = RefreshToken.for_user(user) if include_refresh_token else None
if refresh_token and verified_email_claim is not None:
refresh_token["verified_email_claim"] = verified_email_claim
return prepare_user_tokens_payload(access_token, refresh_token)

View file

@ -32,6 +32,15 @@ def test_get_settings(api_client):
response_json = response.json()
assert "instance_id" not in response_json
assert response_json["allow_new_signups"] is False
assert response_json["email_verification"] == "no_verification"
# None is not returned as a value for email_verification
settings.email_verification = None
settings.save()
response = api_client.get(reverse("api:settings:get"))
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json["email_verification"] == "no_verification"
@pytest.mark.django_db
@ -235,3 +244,34 @@ def test_register_settings_data_type(api_client, data_fixture):
response_json = response.json()
assert len(response_json.keys()) > 1
assert response_json["test_tmp"] == "hello"
@pytest.mark.parametrize("value", [None, "invalid"])
@pytest.mark.django_db
def test_update_email_verification_settings_invalid(api_client, data_fixture, value):
user, token = data_fixture.create_user_and_token(is_staff=True)
response = api_client.patch(
reverse("api:settings:update"),
{"email_verification": value},
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"
assert response_json["detail"]["email_verification"][0]["code"] == "invalid_choice"
@pytest.mark.django_db
def test_update_email_verification_settings(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(is_staff=True)
response = api_client.patch(
reverse("api:settings:update"),
{"email_verification": "recommended"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert CoreHandler().get_settings().email_verification == "recommended"

View file

@ -14,9 +14,11 @@ from rest_framework.status import (
)
from rest_framework_simplejwt.tokens import RefreshToken
from baserow.core.models import BlacklistedToken, UserLogEntry
from baserow.core.handler import CoreHandler
from baserow.core.models import BlacklistedToken, Settings, UserLogEntry
from baserow.core.registries import Plugin, plugin_registry
from baserow.core.user.handler import UserHandler
from baserow.core.user.utils import generate_session_tokens_for_user
from baserow.core.utils import generate_hash
User = get_user_model()
@ -197,6 +199,44 @@ def test_token_auth(api_client, data_fixture):
assert "user" in response_json
@pytest.mark.django_db
def test_token_auth_email_verification_required(api_client, data_fixture):
data_fixture.create_password_provider()
user = data_fixture.create_user(email="test@example.com", password="password")
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.ENFORCED
settings.save()
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@example.com", "password": "password"},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response_json["error"] == "ERROR_EMAIL_VERIFICATION_REQUIRED"
@pytest.mark.django_db
def test_token_auth_email_verification_not_required(api_client, data_fixture):
data_fixture.create_password_provider()
user = data_fixture.create_user(email="test@example.com", password="password")
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.RECOMMENDED
settings.save()
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@example.com", "password": "password"},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert "access_token" in response_json
@pytest.mark.django_db
def test_token_password_auth_disabled(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
@ -340,6 +380,108 @@ def test_refresh_token_is_invalidated_after_password_change(api_client, data_fix
assert response_json["error"] == "ERROR_INVALID_REFRESH_TOKEN"
@pytest.mark.django_db
def test_refresh_token_email_verification_required(api_client, data_fixture):
data_fixture.create_password_provider()
user = data_fixture.create_user(email="test@example.com", password="password")
# obtain refresh token
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@example.com", "password": "password"},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
refresh_token = response_json["refresh_token"]
# change email verification setting
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.ENFORCED
settings.save()
profile = user.profile
profile.email_verified = False
profile.save()
# using the refresh token is not possible any more
response = api_client.post(
reverse("api:user:token_refresh"),
{"refresh_token": refresh_token},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response_json["error"] == "ERROR_EMAIL_VERIFICATION_REQUIRED"
@pytest.mark.django_db
def test_refresh_token_email_verification_not_enforced(api_client, data_fixture):
data_fixture.create_password_provider()
user = data_fixture.create_user(email="test@example.com", password="password")
# obtain refresh token
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@example.com", "password": "password"},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
refresh_token = response_json["refresh_token"]
# change email verification setting
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.RECOMMENDED
settings.save()
profile = user.profile
profile.email_verified = False
profile.save()
# using the refresh token is possible
response = api_client.post(
reverse("api:user:token_refresh"),
{"refresh_token": refresh_token},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
def test_refresh_token_email_verification_not_required(api_client, data_fixture):
user = data_fixture.create_user(email="test@example.com", password="password")
# obtain refresh token
# the auth claim will not be set to password authentication
tokens = generate_session_tokens_for_user(
user, include_refresh_token=True, verified_email_claim=None
)
refresh_token = tokens["refresh_token"]
# change email verification setting
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.ENFORCED
settings.save()
profile = user.profile
profile.email_verified = False
profile.save()
# using the refresh token is possible
response = api_client.post(
reverse("api:user:token_refresh"),
{"refresh_token": refresh_token},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
def test_token_verify(api_client, data_fixture):
class TmpPlugin(Plugin):
@ -384,6 +526,73 @@ def test_token_verify(api_client, data_fixture):
assert response.status_code == HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_token_verify_email_verification_required(api_client, data_fixture):
data_fixture.create_password_provider()
user = data_fixture.create_user(email="test@example.com", password="password")
# obtain refresh token
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@example.com", "password": "password"},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
refresh_token = response_json["refresh_token"]
# change email verification setting
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.ENFORCED
settings.save()
profile = user.profile
profile.email_verified = False
profile.save()
# using the refresh token is not possible any more
response = api_client.post(
reverse("api:user:token_verify"),
{"refresh_token": refresh_token},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response_json["error"] == "ERROR_EMAIL_VERIFICATION_REQUIRED"
@pytest.mark.django_db
def test_token_verify_email_verification_not_required(api_client, data_fixture):
user = data_fixture.create_user(email="test@example.com", password="password")
# obtain refresh token
# the auth claim will not be set to password authentication
tokens = generate_session_tokens_for_user(
user, include_refresh_token=True, verified_email_claim=None
)
refresh_token = tokens["refresh_token"]
# change email verification setting
settings = CoreHandler().get_settings()
settings.email_verification = Settings.EmailVerificationOptions.ENFORCED
settings.save()
profile = user.profile
profile.email_verified = False
profile.save()
# using the refresh token is possible
response = api_client.post(
reverse("api:user:token_verify"),
{"refresh_token": refresh_token},
format="json",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
def test_verify_token_is_invalidated_after_password_change(api_client, data_fixture):
with freeze_time("2020-01-01 12:00"):

View file

@ -670,6 +670,27 @@ def test_password_reset(data_fixture, client):
assert response.status_code == HTTP_400_BAD_REQUEST
@pytest.mark.django_db(transaction=True)
def test_password_reset_email_verified_email(data_fixture, client, mailoutbox):
data_fixture.create_password_provider()
user = data_fixture.create_user()
handler = UserHandler()
signer = handler.get_reset_password_signer()
with freeze_time("2020-01-01 12:00"):
token = signer.dumps(user.id)
response = client.post(
reverse("api:user:reset_password"),
{"token": token, "password": "newpassword"},
format="json",
)
assert response.status_code == 204
user.refresh_from_db()
assert user.profile.email_verified is True
@pytest.mark.django_db
def test_change_password(data_fixture, client):
data_fixture.create_password_provider()
@ -993,3 +1014,114 @@ def test_create_user_password_auth_disabled(api_client, data_fixture):
"error": "ERROR_AUTH_PROVIDER_DISABLED",
"detail": "Authentication provider is disabled.",
}
@pytest.mark.django_db
def test_verify_email_address(client, data_fixture):
user = data_fixture.create_user()
token = UserHandler().create_email_verification_token(user)
response = client.post(
reverse("api:user:verify_email"),
{"token": token},
format="json",
)
assert response.status_code == HTTP_204_NO_CONTENT
user.profile.email_verified is True
@pytest.mark.django_db
def test_verify_email_address_already_verified(client, data_fixture):
user = data_fixture.create_user()
profile = user.profile
profile.email_verified = True
profile.save()
token = UserHandler().create_email_verification_token(user)
response = client.post(
reverse("api:user:verify_email"),
{"token": token},
format="json",
)
assert response.status_code == HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_verify_email_address_inactive_user(client, data_fixture):
user = data_fixture.create_user(is_active=False)
token = UserHandler().create_email_verification_token(user)
response = client.post(
reverse("api:user:verify_email"),
{"token": token},
format="json",
)
assert response.status_code == HTTP_400_BAD_REQUEST
@pytest.mark.django_db(transaction=True)
def test_send_verify_email_address(client, data_fixture, mailoutbox):
user, token = data_fixture.create_user_and_token()
response = client.post(
reverse("api:user:send_verify_email"),
{
"email": user.email,
},
format="json",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert len(mailoutbox) == 1
@pytest.mark.django_db(transaction=True)
def test_send_verify_email_address_user_not_found(client, data_fixture, mailoutbox):
response = client.post(
reverse("api:user:send_verify_email"),
{
"email": "doesntexist@example.com",
},
format="json",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert len(mailoutbox) == 0
@pytest.mark.django_db
def test_send_verify_email_address_already_verified(client, data_fixture, mailoutbox):
user, token = data_fixture.create_user_and_token()
profile = user.profile
profile.email_verified = True
profile.save()
response = client.post(
reverse("api:user:send_verify_email"),
{
"email": user.email,
},
format="json",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert len(mailoutbox) == 0
@pytest.mark.django_db
def test_send_verify_email_address_inactive_user(client, data_fixture, mailoutbox):
user, token = data_fixture.create_user_and_token(is_active=False)
response = client.post(
reverse("api:user:send_verify_email"),
{
"email": user.email,
},
format="json",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert len(mailoutbox) == 0

View file

@ -1,3 +1,5 @@
from django.apps import apps
import pytest
from baserow.core.jobs.constants import JOB_FAILED, JOB_FINISHED, JOB_PENDING
@ -16,7 +18,10 @@ def test_migration_remove_dangling_snapshots(
migrator.migrate(migrate_from)
user = data_fixture.create_user()
user_model = apps.get_model("auth", "User")
user = user_model()
user.save()
workspace = data_fixture.create_workspace(user=user)
database = data_fixture.create_database_application(workspace=workspace)
snapshotted_database = data_fixture.create_database_application(workspace=workspace)
@ -79,11 +84,9 @@ def test_migration_remove_dangling_snapshots(
assert Snapshot.objects.count() == 4
snapshot_ids = set(Snapshot.objects.all().values_list("id", flat=True))
assert snapshot_ids == set(
[
snapshot_finished.id,
snapshot_pending.id,
snapshot_marked_for_deletion.id,
snapshot_failed_but_created.id,
]
)
assert snapshot_ids == {
snapshot_finished.id,
snapshot_pending.id,
snapshot_marked_for_deletion.id,
snapshot_failed_but_created.id,
}

View file

@ -6,7 +6,8 @@ from unittest.mock import MagicMock, patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import update_last_login
from django.db import connections
from django.db import connections, transaction
from django.test import override_settings
import pytest
from freezegun import freeze_time
@ -33,7 +34,9 @@ from baserow.core.models import BlacklistedToken, UserLogEntry, Workspace, Works
from baserow.core.registries import plugin_registry
from baserow.core.user.exceptions import (
DisabledSignupError,
EmailAlreadyVerified,
InvalidPassword,
InvalidVerificationToken,
PasswordDoesNotMatchValidation,
RefreshTokenAlreadyBlacklisted,
ResetPasswordDisabledError,
@ -694,3 +697,111 @@ def test_user_handler_delete_user_log_entries_older_than(data_fixture):
UserHandler().delete_user_log_entries_older_than(cutoff)
assert UserLogEntry.objects.count() == 2
@pytest.mark.django_db
def test_create_email_verification_token(data_fixture):
user = data_fixture.create_user()
user2 = data_fixture.create_user()
user2.email = user.email
with freeze_time("2023-05-05"):
signer = UserHandler()._get_email_verification_signer()
token = UserHandler().create_email_verification_token(user)
token_data = signer.loads(token)
assert token_data["user_id"] == user.id
assert token_data["email"] == user.email
assert token_data["expires_at"] == "2023-05-06T00:00:00+00:00"
token2 = UserHandler().create_email_verification_token(user2)
token_data2 = signer.loads(token2)
assert token_data2["user_id"] == user2.id
assert token_data2["email"] == user2.email
assert token_data2["expires_at"] == "2023-05-06T00:00:00+00:00"
assert token != token2
@pytest.mark.django_db
def test_verify_email_address(data_fixture):
user = data_fixture.create_user()
token = UserHandler().create_email_verification_token(user)
assert user.profile.email_verified is False
UserHandler().verify_email_address(token)
user.refresh_from_db()
assert user.profile.email_verified is True
@pytest.mark.django_db
def test_verify_email_address_expired(data_fixture):
user = data_fixture.create_user()
with freeze_time("2023-05-05"):
token = UserHandler().create_email_verification_token(user)
with freeze_time("2023-05-07"), pytest.raises(InvalidVerificationToken):
UserHandler().verify_email_address(token)
@pytest.mark.django_db
def test_verify_email_address_user_doesnt_exist(data_fixture):
user = data_fixture.create_user()
token = UserHandler().create_email_verification_token(user)
user.delete()
with pytest.raises(InvalidVerificationToken):
UserHandler().verify_email_address(token)
@pytest.mark.django_db
def test_verify_email_address_user_different_email(data_fixture):
user = data_fixture.create_user()
token = UserHandler().create_email_verification_token(user)
user.email = "newemail@example.com"
user.save()
with pytest.raises(InvalidVerificationToken):
UserHandler().verify_email_address(token)
@pytest.mark.django_db
def test_verify_email_address_already_verified(data_fixture):
user = data_fixture.create_user()
token = UserHandler().create_email_verification_token(user)
profile = user.profile
profile.email_verified = True
profile.save()
with pytest.raises(EmailAlreadyVerified):
UserHandler().verify_email_address(token)
@pytest.mark.django_db(transaction=True)
@override_settings(BASEROW_EMBEDDED_SHARE_HOSTNAME="http://test/")
def test_send_email_pending_verification(data_fixture, mailoutbox):
user = data_fixture.create_user()
with transaction.atomic():
UserHandler().send_email_pending_verification(user)
assert len(mailoutbox) == 1
email = mailoutbox[0]
assert email.subject == "Please confirm email"
assert user.email in email.to
html_body = email.alternatives[0][0]
f"http://test/auth/verify-email/" in html_body
@pytest.mark.django_db
def test_send_email_pending_verification_already_verified(data_fixture):
user = data_fixture.create_user()
profile = user.profile
profile.email_verified = True
profile.save()
with pytest.raises(EmailAlreadyVerified):
UserHandler().send_email_pending_verification(user)

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add email verification options to user accounts",
"issue_number": 2264,
"bullet_points": [],
"created_at": "2024-04-08"
}

54
docs/patterns/emails.md Normal file
View file

@ -0,0 +1,54 @@
# Emails
## Creating a new email template
Baserow uses [MJML](https://mjml.io/) framework to define email templates. These templates have to be compiled into their HTML versions so that they can be used by Django.
Start by creating a new template (`*.mjml.eta`) in the core email template folder (`backend/src/baserow/core/templates/baserow`) or in the template folder of the contrib module that the template belongs to.
The template should be in MJML format and will typically inherit a base layout (`base.layout.eta`) common to all emails. Besides formatting itself, the template should be a normal Django template, using the Django template language, and should utilize standard Django syntax for translations.
The template will look something like this:
```html
<% layout("../base.layout.eta") %>
<mj-section>
<mj-column>
<mj-text mj-class="title">{% trans "Title to be translated" %}</mj-text>
<mj-text mj-class="text">
{% blocktrans trimmed %}
Text to be translated
{% endblocktrans %}
</mj-text>
<mj-button mj-class="button" href="{{ some_link_passed_as_context_variable }}">
{% trans "Link text to be translated" %}
</mj-button>
</mj-column>
</mj-section>
```
If you are using the Baserow docker compose development environment started by `./dev.sh` script, the new template should be automatically compiled into its HTML version, resulting in a new file. If that's not the case, follow the instructions in `backend/email_compiler` to compile the template manually.
### Do
- Make sure the received email has both HTML and plain text version.
- Make sure the whole content of the email template is translatable.
- Make sure the links lead correctly to Baserow instance without hard-coding the URL.
### Don't
- Don't edit HTML email templates directly, edit the MJML templates instead and recompile them.
## Sending emails
Subclass `BaseEmailMessage` to define an email message with the correct template and all the needed parameters.
### Do
- Send emails in background Celery tasks.
- Use `with translation.override(user.profile.language)` to send emails in the user's selected language.
## Testing
The Baserow dev environment (using the `./dev.sh` script) automatically starts an instance of [MailHog](https://github.com/mailhog/MailHog) at [http://localhost:8025/](http://localhost:8025/). You can verify that the emails are formatted and sent correctly there.

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-01-31 17:08+0000\n"
"POT-Creation-Date: 2024-04-22 17:45+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"
@ -17,35 +17,36 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: baserow_enterprise/audit_log/job_types.py:143
#: baserow_enterprise/audit_log/job_types.py:37
msgid "User Email"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:144
#: baserow_enterprise/audit_log/job_types.py:41
msgid "User ID"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:145
#: baserow_enterprise/audit_log/job_types.py:45
msgid "Group Name"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:146
#: baserow_enterprise/audit_log/job_types.py:49
msgid "Group ID"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:147
#: baserow_enterprise/audit_log/job_types.py:53
msgid "Action Type"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:148
#: baserow_enterprise/audit_log/job_types.py:57
msgid "Description"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:149
#: baserow_enterprise/audit_log/job_types.py:61
msgid "Timestamp"
msgstr ""
#: baserow_enterprise/audit_log/job_types.py:150
#: baserow_enterprise/audit_log/job_types.py:65
msgid "IP Address"
msgstr ""
@ -61,15 +62,19 @@ msgstr ""
msgid "REDONE"
msgstr ""
#: baserow_enterprise/role/actions.py:30
msgid "Assign role"
#: baserow_enterprise/role/actions.py:28
msgid "Assign multiple roles"
msgstr ""
#: baserow_enterprise/role/actions.py:29
msgid "Multiple roles have been assigned"
msgstr ""
#: baserow_enterprise/role/actions.py:32
#, python-format
msgid ""
"Role %(role_uid)s assigned to %(subject_type)s %(subject_id)s on "
"%(scope_type)s %(scope_id)s"
"Role %(role_uid)s assigned to subject type \"%(subject_type_name)s"
"\" (%(subject_id)s) on scope type \"%(scope_type_name)s\" (%(scope_id)s)."
msgstr ""
#: baserow_enterprise/teams/actions.py:21
@ -86,38 +91,38 @@ msgstr ""
msgid "Team \"%(name)s\" (%(team_id)s) created."
msgstr ""
#: baserow_enterprise/teams/actions.py:101
#: baserow_enterprise/teams/actions.py:105
msgid "Update team"
msgstr ""
#: baserow_enterprise/teams/actions.py:102
#: baserow_enterprise/teams/actions.py:106
#, python-format
msgid "Team \"%(name)s\" (%(team_id)s) updated."
msgstr ""
#: baserow_enterprise/teams/actions.py:193
#: baserow_enterprise/teams/actions.py:203
msgid "Delete team"
msgstr ""
#: baserow_enterprise/teams/actions.py:194
#: baserow_enterprise/teams/actions.py:204
#, python-format
msgid "Team \"%(team_name)s\" (%(team_id)s) deleted."
msgstr ""
#: baserow_enterprise/teams/actions.py:240
#: baserow_enterprise/teams/actions.py:253
msgid "Create team subject"
msgstr ""
#: baserow_enterprise/teams/actions.py:240
#: baserow_enterprise/teams/actions.py:253
#, python-format
msgid "Subject (%(subject_id)s) created"
msgstr ""
#: baserow_enterprise/teams/actions.py:320
#: baserow_enterprise/teams/actions.py:338
msgid "Delete team subject"
msgstr ""
#: baserow_enterprise/teams/actions.py:321
#: baserow_enterprise/teams/actions.py:339
#, python-format
msgid "Subject (%(subject_id)s) deleted"
msgstr ""

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-06-13 16:45+0000\n"
"POT-Creation-Date: 2024-04-22 17:45+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"
@ -18,29 +18,39 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: src/baserow_premium/row_comments/actions.py:22
#: src/baserow_premium/row_comments/actions.py:23
msgid "Create row comment"
msgstr ""
#: src/baserow_premium/row_comments/actions.py:23
#: src/baserow_premium/row_comments/actions.py:24
#, python-format
msgid "Comment (%(comment_id)s) has been added to row (%(row_id)s)"
msgstr ""
#: src/baserow_premium/row_comments/actions.py:96
#: src/baserow_premium/row_comments/actions.py:108
msgid "Update row comment"
msgstr ""
#: src/baserow_premium/row_comments/actions.py:97
#: src/baserow_premium/row_comments/actions.py:109
#, python-format
msgid "Comment (%(comment_id)s) has been updated in row (%(row_id)s)"
msgstr ""
#: src/baserow_premium/row_comments/actions.py:186
#: src/baserow_premium/row_comments/actions.py:203
msgid "Delete row comment"
msgstr ""
#: src/baserow_premium/row_comments/actions.py:187
#: src/baserow_premium/row_comments/actions.py:204
#, python-format
msgid "Comment (%(comment_id)s) has been deleted from row (%(row_id)s)"
msgstr ""
#: src/baserow_premium/row_comments/notification_types.py:76
#, python-format
msgid "%(user)s mentioned you in row %(row_name)s in %(table_name)s."
msgstr ""
#: src/baserow_premium/row_comments/notification_types.py:129
#, python-format
msgid "%(user)s posted a comment in row %(row_name)s in %(table_name)s."
msgstr ""

View file

@ -0,0 +1,35 @@
<template>
<div>
<div class="box__message">
<div class="box__message-icon">
<i class="iconoir-mail-out"></i>
</div>
<p class="box__message-text">
{{ $t('emailNotVerified.description') }}
</p>
<Button
type="secondary"
size="large"
:disabled="resendLoading || resendSuccess"
:loading="resendLoading"
@click="resend(email)"
>
{{ $t('emailNotVerified.resend') }}
</Button>
</div>
</div>
</template>
<script>
import resendEmailVerification from '@baserow/modules/core/mixins/resendEmailVerification'
export default {
mixins: [resendEmailVerification],
props: {
email: {
type: String,
required: true,
},
},
}
</script>

View file

@ -1,61 +1,66 @@
<template>
<div>
<div v-if="displayHeader">
<div class="auth__logo">
<nuxt-link :to="{ name: 'index' }">
<Logo />
</nuxt-link>
</div>
<div class="auth__head">
<h1 class="auth__head-title">
{{ $t('login.title') }}
</h1>
<LangPicker />
</div>
</div>
<div v-if="redirectByDefault && defaultRedirectUrl">
{{ $t('login.redirecting') }}
</div>
<div v-else>
<LoginButtons
show-border="bottom"
:hide-if-no-buttons="loginButtonsCompact"
:invitation="invitation"
:original="original"
/>
<PasswordLogin
v-if="!passwordLoginHidden"
:invitation="invitation"
@success="success"
>
</PasswordLogin>
<LoginActions :invitation="invitation" :original="original">
<li v-if="passwordLoginHidden">
<a @click="passwordLoginHiddenIfDisabled = false">
{{ $t('login.displayPasswordLogin') }}
</a>
</li>
<li v-if="settings.allow_reset_password && !passwordLoginHidden">
<nuxt-link :to="{ name: 'forgot-password' }">
{{ $t('login.forgotPassword') }}
<EmailNotVerified v-if="displayEmailNotVerified" :email="emailToVerify">
</EmailNotVerified>
<template v-if="!displayEmailNotVerified">
<div v-if="displayHeader">
<div class="auth__logo">
<nuxt-link :to="{ name: 'index' }">
<Logo />
</nuxt-link>
</li>
<li v-if="settings.allow_new_signups">
<slot name="signup">
{{ $t('login.signUpText') }}
<nuxt-link :to="{ name: 'signup' }">
{{ $t('login.signUp') }}
</div>
<div class="auth__head">
<h1 class="auth__head-title">
{{ $t('login.title') }}
</h1>
<LangPicker />
</div>
</div>
<div v-if="redirectByDefault && defaultRedirectUrl">
{{ $t('login.redirecting') }}
</div>
<div v-else>
<LoginButtons
show-border="bottom"
:hide-if-no-buttons="loginButtonsCompact"
:invitation="invitation"
:original="original"
/>
<PasswordLogin
v-if="!passwordLoginHidden"
:invitation="invitation"
@success="success"
@email-not-verified="emailNotVerified"
>
</PasswordLogin>
<LoginActions :invitation="invitation" :original="original">
<li v-if="passwordLoginHidden">
<a @click="passwordLoginHiddenIfDisabled = false">
{{ $t('login.displayPasswordLogin') }}
</a>
</li>
<li v-if="settings.allow_reset_password && !passwordLoginHidden">
<nuxt-link :to="{ name: 'forgot-password' }">
{{ $t('login.forgotPassword') }}
</nuxt-link>
</slot>
</li>
</LoginActions>
</div>
</li>
<li v-if="settings.allow_new_signups">
<slot name="signup">
{{ $t('login.signUpText') }}
<nuxt-link :to="{ name: 'signup' }">
{{ $t('login.signUp') }}
</nuxt-link>
</slot>
</li>
</LoginActions>
</div>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import EmailNotVerified from '@baserow/modules/core/components/auth/EmailNotVerified.vue'
import LoginButtons from '@baserow/modules/core/components/auth/LoginButtons'
import LoginActions from '@baserow/modules/core/components/auth/LoginActions'
import PasswordLogin from '@baserow/modules/core/components/auth/PasswordLogin'
@ -67,7 +72,13 @@ import {
import VueRouter from 'vue-router'
export default {
components: { PasswordLogin, LoginButtons, LangPicker, LoginActions },
components: {
PasswordLogin,
LoginButtons,
LangPicker,
LoginActions,
EmailNotVerified,
},
props: {
original: {
type: String,
@ -103,6 +114,8 @@ export default {
data() {
return {
passwordLoginHiddenIfDisabled: true,
displayEmailNotVerified: false,
emailToVerify: null,
}
},
computed: {
@ -163,6 +176,10 @@ export default {
}
}
},
emailNotVerified(email) {
this.displayEmailNotVerified = true
this.emailToVerify = email
},
},
}
</script>

View file

@ -139,6 +139,13 @@ export default {
}
}
}
const emailVerified = this.$route.query.emailVerified
if (emailVerified) {
this.$store.dispatch('toast/info', {
title: this.$i18n.t('verifyEmailAddress.emailVerifiedTitle'),
message: this.$i18n.t('verifyEmailAddress.emailVerifiedDescription'),
})
}
},
methods: {
async login() {
@ -184,6 +191,10 @@ export default {
this.$t('clientHandler.disabledPasswordProviderTitle'),
this.$t('clientHandler.disabledPasswordProviderMessage')
)
} else if (
response.data?.error === 'ERROR_EMAIL_VERIFICATION_REQUIRED'
) {
this.$emit('email-not-verified', this.values.email)
} else {
this.showError(
this.$t('error.incorrectCredentialTitle'),

View file

@ -225,7 +225,7 @@ export default {
plugin.userCreated(this.account, this)
})
this.$emit('success')
this.$emit('success', { email: values.email })
} catch (error) {
this.loading = false
this.handleError(error, 'signup', {

View file

@ -0,0 +1,57 @@
<template>
<Alert v-if="shouldBeDisplayed" type="info-primary">
<template #title> {{ $t('dashboardVerifyEmail.title') }}</template>
<template #actions>
<Button
type="primary"
size="small"
:disabled="resendLoading || resendSuccess"
:loading="resendLoading"
@click="resend(user.username)"
>
{{ $t('dashboardVerifyEmail.resendConfirmationEmail') }}
</Button>
</template>
</Alert>
</template>
<script>
import { mapState } from 'vuex'
import { EMAIL_VERIFICATION_OPTIONS } from '@baserow/modules/core/enums'
import resendEmailVerification from '@baserow/modules/core/mixins/resendEmailVerification'
export default {
name: 'DashboardVerifyEmail',
mixins: [resendEmailVerification],
computed: {
...mapState({
user: (state) => state.auth.user,
settings: (state) => state.settings.settings,
refreshTokenPayload: (state) => state.auth.refreshTokenPayload,
}),
shouldBeDisplayed() {
if (
[
EMAIL_VERIFICATION_OPTIONS.RECOMMENDED,
EMAIL_VERIFICATION_OPTIONS.ENFORCED,
].includes(this.settings.email_verification) &&
this.user.email_verified === false &&
this.refreshTokenPayload?.verified_email_claim ===
EMAIL_VERIFICATION_OPTIONS.ENFORCED
) {
return true
}
return false
},
},
mounted() {
const emailVerified = this.$route.query.emailVerified
if (emailVerified) {
this.$store.dispatch('toast/info', {
title: this.$i18n.t('verifyEmailAddress.emailVerifiedTitle'),
message: this.$i18n.t('verifyEmailAddress.emailVerifiedDescription'),
})
}
},
}
</script>

View file

@ -1,5 +1,13 @@
export const IMAGE_FILE_TYPES = ['image/jpeg', 'image/jpg', 'image/png']
// Keep these in sync with the backend options in
// baserow.core.models.Settings.EmailVerificationOptions
export const EMAIL_VERIFICATION_OPTIONS = {
NO_VERIFICATION: 'no_verification',
RECOMMENDED: 'recommended',
ENFORCED: 'enforced',
}
// Keep these in sync with the backend options in
// baserow.core.models.UserProfile.EmailNotificationFrequencyOptions
export const EMAIL_NOTIFICATIONS_FREQUENCY_OPTIONS = {

View file

@ -124,6 +124,23 @@
"dashboardWorkspace": {
"createApplication": "Create new"
},
"dashboardVerifyEmail": {
"title": "Please check your mailbox and click on the link to verify your email.",
"resendConfirmationEmail": "Resend confirmation email"
},
"resendEmailVerification": {
"confirmationEmailSentTitle": "Confirmation email sent",
"confirmationEmailSentDescription": "The confirmation email has been sent."
},
"verifyEmailAddress": {
"emailVerifiedTitle": "Email verified",
"emailVerifiedDescription": "The email address has been verified."
},
"emailNotVerified": {
"title": "Email verification required",
"description": "Please check your mailbox and click on the link to verify. Once verified, your account will be activated.",
"resend": "Resend confirmation email"
},
"workspaceInvitation": {
"title": "Invitation",
"message": "{by} has invited you to join {workspace}.",
@ -405,12 +422,17 @@
"settingAllowNonStaffCreateWorkspaceOperationWarning": "New users will have a workspace automatically created for them where they are an Admin.",
"settingTrackWorkspaceUsage": "Track workspace usage",
"settingTrackWorkspaceUsageDescription": "Enables a nightly job that counts row and file usage per workspace, displayed on the premium workspace admin page.",
"userDeletionGraceDelay": "User deletion",
"settingUserDeletionGraceDelay": "Grace delay",
"userSettings": "User",
"settingUserDeletionGraceDelay": "Delete grace delay",
"settingUserDeletionGraceDelayDescription": "This is the number of days without a login after which an account scheduled for deletion is permanently deleted.",
"invalidAccountDeletionGraceDelay": "This value is required and must be a positive integer smaller than 32000",
"enabled": "enabled",
"maintenance": "Maintenance"
"maintenance": "Maintenance",
"emailVerification": "Email verification",
"emailVerificationDescription": "Controls whether email addresses have to be verified.",
"emailVerificationNoVerification": "No verification",
"emailVerificationRecommended": "Recommended",
"emailVerificationEnforced": "Enforced"
},
"formSidebar": {
"actions": {

View file

@ -0,0 +1,37 @@
import AuthService from '@baserow/modules/core/services/auth'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
data() {
return {
resendLoading: false,
resendSuccess: false,
}
},
methods: {
async resend(email) {
if (this.resendLoading) {
return
}
this.resendLoading = true
try {
await AuthService(this.$client).sendVerifyEmail(email)
this.resendSuccess = true
this.$store.dispatch('toast/info', {
title: this.$i18n.t(
'resendEmailVerification.confirmationEmailSentTitle'
),
message: this.$i18n.t(
'resendEmailVerification.confirmationEmailSentDescription'
),
})
} catch (error) {
notifyIf(error, 'emailVerification')
}
this.resendLoading = false
},
},
}

View file

@ -130,8 +130,49 @@
</div>
<div class="admin-settings__group">
<h2 class="admin-settings__group-title">
{{ $t('settings.userDeletionGraceDelay') }}
{{ $t('settings.userSettings') }}
</h2>
<div class="admin-settings__item">
<div class="admin-settings__label">
<div class="admin-settings__name">
{{ $t('settings.emailVerification') }}
</div>
<div class="admin-settings__description">
{{ $t('settings.emailVerificationDescription') }}
</div>
</div>
<div class="admin-settings__control">
<Radio
:value="EMAIL_VERIFICATION_OPTIONS.NO_VERIFICATION"
:model-value="settings.email_verification"
@input="updateSettings({ email_verification: $event })"
>
{{ $t('settings.emailVerificationNoVerification') }}
</Radio>
<Radio
:value="EMAIL_VERIFICATION_OPTIONS.RECOMMENDED"
:model-value="settings.email_verification"
@input="
updateSettings({
email_verification: $event,
})
"
>
{{ $t('settings.emailVerificationRecommended') }}
</Radio>
<Radio
:value="EMAIL_VERIFICATION_OPTIONS.ENFORCED"
:model-value="settings.email_verification"
@input="
updateSettings({
email_verification: $event,
})
"
>
{{ $t('settings.emailVerificationEnforced') }}
</Radio>
</div>
</div>
<div class="admin-settings__item">
<div class="admin-settings__label">
<div class="admin-settings__name">
@ -201,6 +242,8 @@ import { notifyIf } from '@baserow/modules/core/utils/error'
import SettingsService from '@baserow/modules/core/services/settings'
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
import { EMAIL_VERIFICATION_OPTIONS } from '@baserow/modules/core/enums'
export default {
layout: 'app',
middleware: 'staff',
@ -224,11 +267,19 @@ export default {
...mapGetters({
settings: 'settings/get',
}),
EMAIL_VERIFICATION_OPTIONS() {
return EMAIL_VERIFICATION_OPTIONS
},
},
watch: {
'settings.account_deletion_grace_delay'(value) {
this.account_deletion_grace_delay = value
},
account_deletion_grace_delay(value) {
if (this.dataInitialized) {
this.updateSettings({ account_deletion_grace_delay: value })
}
},
},
mounted() {
this.account_deletion_grace_delay =

View file

@ -11,6 +11,7 @@
:key="index"
></component>
</template>
<DashboardVerifyEmail></DashboardVerifyEmail>
<WorkspaceInvitation
v-for="invitation in workspaceInvitations"
:key="'invitation-' + invitation.id"
@ -69,6 +70,7 @@ import DashboardWorkspace from '@baserow/modules/core/components/dashboard/Dashb
import DashboardHelp from '@baserow/modules/core/components/dashboard/DashboardHelp'
import DashboardNoWorkspaces from '@baserow/modules/core/components/dashboard/DashboardNoWorkspaces'
import DashboardSidebar from '@baserow/modules/core/components/dashboard/DashboardSidebar'
import DashboardVerifyEmail from '@baserow/modules/core/components/dashboard/DashboardVerifyEmail'
export default {
components: {
@ -78,6 +80,7 @@ export default {
CreateWorkspaceModal,
DashboardWorkspace,
WorkspaceInvitation,
DashboardVerifyEmail,
},
layout: 'app',
/**

View file

@ -20,7 +20,11 @@ export default {
if (store.getters['settings/get'].show_admin_signup_page === true) {
return redirect({ name: 'signup' })
} else if (store.getters['auth/isAuthenticated']) {
return redirect({ name: 'dashboard' })
const newQueryParams = {}
if (route.query.emailVerified) {
newQueryParams.emailVerified = true
}
return redirect({ name: 'dashboard', query: newQueryParams })
}
await store.dispatch('authProvider/fetchLoginOptions')
return await workspaceInvitationToken.asyncData({ route, app })

View file

@ -1,59 +1,63 @@
<template>
<div>
<div class="auth__logo">
<nuxt-link :to="{ name: 'index' }">
<Logo />
</nuxt-link>
</div>
<div class="auth__head auth__head--more-margin">
<h1 class="auth__head-title">
{{ $t('signup.title') }}
</h1>
<LangPicker />
</div>
<template v-if="shouldShowAdminSignupPage">
<Alert>
<template #title>{{ $t('signup.requireFirstUser') }}</template>
<p>{{ $t('signup.requireFirstUserMessage') }}</p></Alert
>
</template>
<template v-if="!isSignupEnabled">
<Alert type="error">
<template #title>{{ $t('signup.disabled') }}</template>
<p>{{ $t('signup.disabledMessage') }}</p></Alert
>
<nuxt-link :to="{ name: 'login' }" class="button button--full-width">
{{ $t('action.backToLogin') }}
</nuxt-link>
</template>
<template v-else>
<PasswordRegister
v-if="afterSignupStep < 0 && passwordLoginEnabled"
:invitation="invitation"
@success="next"
>
</PasswordRegister>
<LoginButtons
v-if="afterSignupStep < 0"
show-border="top"
:hide-if-no-buttons="true"
:invitation="invitation"
/>
<LoginActions
v-if="!shouldShowAdminSignupPage && afterSignupStep < 0"
:invitation="invitation"
>
<li>
{{ $t('signup.loginText') }}
<nuxt-link :to="{ name: 'login' }">
{{ $t('action.login') }}
</nuxt-link>
</li>
</LoginActions>
<component
:is="afterSignupStepComponents[afterSignupStep]"
@success="next"
></component>
<EmailNotVerified v-if="displayEmailNotVerified" :email="emailToVerify">
</EmailNotVerified>
<template v-if="!displayEmailNotVerified">
<div class="auth__logo">
<nuxt-link :to="{ name: 'index' }">
<Logo />
</nuxt-link>
</div>
<div class="auth__head auth__head--more-margin">
<h1 class="auth__head-title">
{{ $t('signup.title') }}
</h1>
<LangPicker />
</div>
<template v-if="shouldShowAdminSignupPage">
<Alert>
<template #title>{{ $t('signup.requireFirstUser') }}</template>
<p>{{ $t('signup.requireFirstUserMessage') }}</p></Alert
>
</template>
<template v-if="!isSignupEnabled">
<Alert type="error">
<template #title>{{ $t('signup.disabled') }}</template>
<p>{{ $t('signup.disabledMessage') }}</p></Alert
>
<nuxt-link :to="{ name: 'login' }" class="button button--full-width">
{{ $t('action.backToLogin') }}
</nuxt-link>
</template>
<template v-else>
<PasswordRegister
v-if="afterSignupStep < 0 && passwordLoginEnabled"
:invitation="invitation"
@success="next"
>
</PasswordRegister>
<LoginButtons
v-if="afterSignupStep < 0"
show-border="top"
:hide-if-no-buttons="true"
:invitation="invitation"
/>
<LoginActions
v-if="!shouldShowAdminSignupPage && afterSignupStep < 0"
:invitation="invitation"
>
<li>
{{ $t('signup.loginText') }}
<nuxt-link :to="{ name: 'login' }">
{{ $t('action.login') }}
</nuxt-link>
</li>
</LoginActions>
<component
:is="afterSignupStepComponents[afterSignupStep]"
@success="next"
></component>
</template>
</template>
</div>
</template>
@ -65,9 +69,17 @@ import LangPicker from '@baserow/modules/core/components/LangPicker'
import LoginButtons from '@baserow/modules/core/components/auth/LoginButtons'
import LoginActions from '@baserow/modules/core/components/auth/LoginActions'
import workspaceInvitationToken from '@baserow/modules/core/mixins/workspaceInvitationToken'
import { EMAIL_VERIFICATION_OPTIONS } from '@baserow/modules/core/enums'
import EmailNotVerified from '@baserow/modules/core/components/auth/EmailNotVerified.vue'
export default {
components: { PasswordRegister, LangPicker, LoginButtons, LoginActions },
components: {
PasswordRegister,
LangPicker,
LoginButtons,
LoginActions,
EmailNotVerified,
},
layout: 'login',
async asyncData({ app, route, store, redirect }) {
if (store.getters['auth/isAuthenticated']) {
@ -79,6 +91,8 @@ export default {
data() {
return {
afterSignupStep: -1,
displayEmailNotVerified: false,
emailToVerify: null,
}
},
head() {
@ -112,9 +126,20 @@ export default {
}),
},
methods: {
next() {
next(params) {
if (params?.email) {
this.emailToVerify = params.email
}
if (this.afterSignupStep + 1 < this.afterSignupStepComponents.length) {
this.afterSignupStep++
} else if (
this.emailToVerify &&
this.settings.email_verification ===
EMAIL_VERIFICATION_OPTIONS.ENFORCED &&
!this.$route.query.workspaceInvitationToken
) {
this.displayEmailNotVerified = true
} else {
this.$nuxt.$router.push({ name: 'dashboard' }, () => {
this.$store.dispatch('settings/hideAdminSignupPage')

View file

@ -0,0 +1,20 @@
<script>
import AuthService from '@baserow/modules/core/services/auth'
export default {
async asyncData({ store, params, error, app, redirect }) {
const { token } = params
try {
await AuthService(app.$client).verifyEmail(token)
} catch {
return error({
statusCode: 404,
message: 'Not a valid confirmation token.',
})
}
return redirect({ name: 'login', query: { emailVerified: true } })
},
}
</script>

View file

@ -31,6 +31,11 @@ export const routes = [
path: '/reset-password/:token',
component: path.resolve(__dirname, 'pages/resetPassword.vue'),
},
{
name: 'verify-email-address',
path: '/verify-email-address/:token',
component: path.resolve(__dirname, 'pages/verifyEmailAddress.vue'),
},
{
name: 'dashboard',
path: '/dashboard',

View file

@ -60,6 +60,16 @@ export default (client) => {
new_password: newPassword,
})
},
sendVerifyEmail(email) {
return client.post(`/user/send-verify-email/`, {
email,
})
},
verifyEmail(token) {
return client.post(`/user/verify-email/`, {
token,
})
},
dashboard() {
return client.get('/user/dashboard/')
},

View file

@ -14,6 +14,7 @@ export const state = () => ({
refreshToken: null,
tokenUpdatedAt: 0,
tokenPayload: null,
refreshTokenPayload: null,
permissions: [],
user: null,
authenticated: false,
@ -45,6 +46,9 @@ export const mutations = {
state.refreshToken = refresh_token
state.tokenUpdatedAt = tokenUpdatedAt || new Date().getTime()
state.tokenPayload = jwtDecode(state.token)
if (state.refreshToken) {
state.refreshTokenPayload = jwtDecode(state.refreshToken)
}
// Global permissions annotated on the User.
state.permissions = permissions
/* eslint-enable camelcase */
@ -72,6 +76,7 @@ export const mutations = {
state.refreshToken = null
state.tokenUpdatedAt = 0
state.tokenPayload = null
state.refreshTokenPayload = null
state.authenticated = false
state.permissions = []
},
@ -80,6 +85,7 @@ export const mutations = {
state.refreshToken = null
state.tokenUpdatedAt = 0
state.tokenPayload = null
state.refreshTokenPayload = null
state.user = null
state.authenticated = false
state.permissions = []
@ -157,8 +163,11 @@ export const actions = {
workspaceInvitationToken,
templateId
)
setToken(this.app, data.refresh_token)
dispatch('setUserData', data)
if (data.refresh_token) {
setToken(this.app, data.refresh_token)
dispatch('setUserData', data)
}
},
/**
* Logs off the user by removing the token as a cookie and clearing the user
@ -317,6 +326,9 @@ export const getters = {
refreshToken(state) {
return state.refreshToken
},
refreshTokenPayload(state) {
return state.refreshTokenPayload
},
webSocketId(state) {
return state.webSocketId
},