mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-17 18:32:35 +00:00
Add user account deletion process
This commit is contained in:
parent
3e28c3f15c
commit
64c8e81c96
52 changed files with 1880 additions and 208 deletions
backend
src/baserow
api
contrib/database
api/views
export
locale/en/LC_MESSAGES
migrations
core
handler.py
locale/en/LC_MESSAGES
migrations
models.pytasks.pytemplates/baserow/core/user
account_deleted.htmlaccount_deleted.mjml.etaaccount_deletion_cancelled.htmlaccount_deletion_cancelled.mjml.etaaccount_deletion_scheduled.htmlaccount_deletion_scheduled.mjml.eta
user
test_utils/fixtures
tests/baserow
api/users
contrib/database/export
core/user
premium
backend/src/baserow_premium
web-frontend/modules/baserow_premium
web-frontend
locales
modules/core
|
@ -12,12 +12,20 @@ User = get_user_model()
|
|||
|
||||
|
||||
class GroupUserSerializer(serializers.ModelSerializer):
|
||||
name = serializers.SerializerMethodField()
|
||||
email = serializers.SerializerMethodField()
|
||||
name = serializers.SerializerMethodField(help_text="User defined name.")
|
||||
email = serializers.SerializerMethodField(help_text="User email.")
|
||||
|
||||
class Meta:
|
||||
model = GroupUser
|
||||
fields = ("id", "name", "email", "group", "permissions", "created_on")
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"group",
|
||||
"permissions",
|
||||
"created_on",
|
||||
"user_id",
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_name(self, object):
|
||||
|
|
|
@ -10,11 +10,13 @@ class SettingsSerializer(serializers.ModelSerializer):
|
|||
"allow_new_signups",
|
||||
"allow_signups_via_group_invitations",
|
||||
"allow_reset_password",
|
||||
"account_deletion_grace_delay",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"allow_new_signups": {"required": False},
|
||||
"allow_signups_via_group_invitations": {"required": False},
|
||||
"allow_reset_password": {"required": False},
|
||||
"account_deletion_grace_delay": {"required": False},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ from django.conf import settings
|
|||
ERROR_ALREADY_EXISTS = "ERROR_EMAIL_ALREADY_EXISTS" # nosec
|
||||
ERROR_USER_NOT_FOUND = "ERROR_USER_NOT_FOUND" # nosec
|
||||
ERROR_INVALID_OLD_PASSWORD = "ERROR_INVALID_OLD_PASSWORD" # nosec
|
||||
ERROR_INVALID_PASSWORD = "ERROR_INVALID_PASSWORD" # nosec
|
||||
ERROR_USER_IS_LAST_ADMIN = "ERROR_USER_IS_LAST_ADMIN"
|
||||
ERROR_DISABLED_SIGNUP = "ERROR_DISABLED_SIGNUP" # nosec
|
||||
ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET = (
|
||||
"ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET",
|
||||
|
|
|
@ -2,7 +2,6 @@ from typing import List
|
|||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
@ -13,8 +12,9 @@ from baserow.api.mixins import UnknownFieldRaisesExceptionSerializerMixin
|
|||
from baserow.api.user.validators import password_validation, language_validation
|
||||
from baserow.core.action.models import Action
|
||||
from baserow.core.action.registries import action_scope_registry, ActionScopeStr
|
||||
from baserow.core.models import Template, UserLogEntry
|
||||
from baserow.core.models import Template
|
||||
from baserow.core.user.utils import normalize_email_address
|
||||
from baserow.core.user.handler import UserHandler
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -223,6 +223,10 @@ class ChangePasswordBodyValidationSerializer(serializers.Serializer):
|
|||
new_password = serializers.CharField(validators=[password_validation])
|
||||
|
||||
|
||||
class DeleteUserBodyValidationSerializer(serializers.Serializer):
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class NormalizedEmailField(serializers.EmailField):
|
||||
def to_internal_value(self, data):
|
||||
data = super().to_internal_value(data)
|
||||
|
@ -250,14 +254,8 @@ class NormalizedEmailWebTokenSerializer(JSONWebTokenSerializer):
|
|||
msg = "User account is disabled."
|
||||
raise serializers.ValidationError(msg)
|
||||
|
||||
update_last_login(None, user)
|
||||
UserLogEntry.objects.create(actor=user, action="SIGNED_IN")
|
||||
# Call the user_signed_in method for each plugin that is un the registry to
|
||||
# notify all plugins that a user has signed in.
|
||||
from baserow.core.registries import plugin_registry
|
||||
UserHandler().user_signed_in(user)
|
||||
|
||||
for plugin in plugin_registry.registry.values():
|
||||
plugin.user_signed_in(user)
|
||||
return validated_data
|
||||
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ from django.urls import re_path
|
|||
|
||||
from .views import (
|
||||
AccountView,
|
||||
ScheduleAccountDeletionView,
|
||||
UserView,
|
||||
SendResetPasswordEmailView,
|
||||
ResetPasswordView,
|
||||
|
@ -19,6 +20,11 @@ app_name = "baserow.api.user"
|
|||
|
||||
urlpatterns = [
|
||||
re_path(r"^account/$", AccountView.as_view(), name="account"),
|
||||
re_path(
|
||||
r"^schedule-account-deletion/$",
|
||||
ScheduleAccountDeletionView.as_view(),
|
||||
name="schedule_account_deletion",
|
||||
),
|
||||
re_path(r"^token-auth/$", ObtainJSONWebToken.as_view(), name="token_auth"),
|
||||
re_path(r"^token-refresh/$", RefreshJSONWebToken.as_view(), name="token_refresh"),
|
||||
re_path(r"^token-verify/$", VerifyJSONWebToken.as_view(), name="token_verify"),
|
||||
|
|
|
@ -2,6 +2,7 @@ from typing import List
|
|||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
|
||||
|
@ -37,6 +38,7 @@ from baserow.core.exceptions import (
|
|||
from baserow.core.models import GroupInvitation, Template
|
||||
from baserow.core.user.exceptions import (
|
||||
UserAlreadyExist,
|
||||
UserIsLastAdmin,
|
||||
UserNotFound,
|
||||
InvalidPassword,
|
||||
DisabledSignupError,
|
||||
|
@ -46,8 +48,10 @@ from baserow.core.user.handler import UserHandler
|
|||
from baserow.api.sessions import get_untrusted_client_session_id
|
||||
from .errors import (
|
||||
ERROR_ALREADY_EXISTS,
|
||||
ERROR_USER_IS_LAST_ADMIN,
|
||||
ERROR_USER_NOT_FOUND,
|
||||
ERROR_INVALID_OLD_PASSWORD,
|
||||
ERROR_INVALID_PASSWORD,
|
||||
ERROR_DISABLED_SIGNUP,
|
||||
ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET,
|
||||
ERROR_DISABLED_RESET_PASSWORD,
|
||||
|
@ -61,6 +65,7 @@ from .serializers import (
|
|||
SendResetPasswordEmailBodyValidationSerializer,
|
||||
ResetPasswordBodyValidationSerializer,
|
||||
ChangePasswordBodyValidationSerializer,
|
||||
DeleteUserBodyValidationSerializer,
|
||||
NormalizedEmailWebTokenSerializer,
|
||||
DashboardSerializer,
|
||||
UndoRedoRequestSerializer,
|
||||
|
@ -361,7 +366,7 @@ class AccountView(APIView):
|
|||
@transaction.atomic
|
||||
@validate_body(AccountSerializer)
|
||||
def patch(self, request, data):
|
||||
"""Update editable user account information."""
|
||||
"""Updates editable user account information."""
|
||||
|
||||
user = UserHandler().update_user(
|
||||
request.user,
|
||||
|
@ -370,6 +375,46 @@ class AccountView(APIView):
|
|||
return Response(AccountSerializer(user).data)
|
||||
|
||||
|
||||
class ScheduleAccountDeletionView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["User"],
|
||||
request=DeleteUserBodyValidationSerializer,
|
||||
operation_id="schedule_account_deletion",
|
||||
description=(
|
||||
"Schedules the account deletion of the authenticated user. "
|
||||
"The user will be permanently deleted after the grace delay defined "
|
||||
"by the instance administrator."
|
||||
),
|
||||
responses={
|
||||
204: None,
|
||||
400: get_error_schema(
|
||||
[
|
||||
"ERROR_INVALID_PASSWORD",
|
||||
"ERROR_REQUEST_BODY_VALIDATION",
|
||||
]
|
||||
),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions(
|
||||
{
|
||||
InvalidPassword: ERROR_INVALID_PASSWORD,
|
||||
UserIsLastAdmin: ERROR_USER_IS_LAST_ADMIN,
|
||||
}
|
||||
)
|
||||
@validate_body(DeleteUserBodyValidationSerializer)
|
||||
def post(self, request, data):
|
||||
"""Schedules user account deletion."""
|
||||
|
||||
UserHandler().schedule_user_deletion(
|
||||
request.user,
|
||||
**data,
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class DashboardView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
|
|
@ -1090,7 +1090,7 @@ class ViewDecorationView(APIView):
|
|||
responses={
|
||||
204: None,
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_VIEW_decoration_DOES_NOT_EXIST"]),
|
||||
404: get_error_schema(["ERROR_VIEW_DECORATION_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
|
|
|
@ -27,11 +27,11 @@ EXPORT_JOB_RUNNING_STATUSES = [EXPORT_JOB_PENDING_STATUS, EXPORT_JOB_EXPORTING_S
|
|||
|
||||
|
||||
class ExportJob(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
table = models.ForeignKey(Table, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
table = models.ForeignKey(Table, on_delete=models.SET_NULL, null=True)
|
||||
# An export job might be for just a table and not a particular view of that table
|
||||
# , in that situation the view will be None.
|
||||
view = models.ForeignKey(View, on_delete=models.CASCADE, null=True, blank=True)
|
||||
view = models.ForeignKey(View, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
# New exporter types might be registered dynamically by plugins hence we can't
|
||||
# restrict this field to a particular choice of options as we don't know them.
|
||||
exporter_type = models.TextField()
|
||||
|
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-04-22 08:45+0000\n"
|
||||
"POT-Creation-Date: 2022-06-10 06:28+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"
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
# Generated by Django 3.2.13 on 2022-06-14 14:38
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("database", "0074_auto_20220530_0919"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="exportjob",
|
||||
name="table",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="database.table",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="exportjob",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="exportjob",
|
||||
name="view",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="database.view",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -110,6 +110,7 @@ class CoreHandler:
|
|||
"allow_new_signups",
|
||||
"allow_signups_via_group_invitations",
|
||||
"allow_reset_password",
|
||||
"account_deletion_grace_delay",
|
||||
],
|
||||
settings_instance,
|
||||
)
|
||||
|
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-04-22 08:45+0000\n"
|
||||
"POT-Creation-Date: 2022-06-10 06:28+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"
|
||||
|
@ -39,6 +39,9 @@ msgid "Accept invitation"
|
|||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/group_invitation.html:179
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:156
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:156
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:161
|
||||
#: src/baserow/core/templates/baserow/core/user/reset_password.html:179
|
||||
msgid ""
|
||||
"Baserow is an open source no-code database tool which allows you to "
|
||||
|
@ -46,6 +49,46 @@ msgid ""
|
|||
"developer without leaving your browser."
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:146
|
||||
msgid "Account permanently deleted"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:151
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
|
||||
"has been permanently deleted."
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:146
|
||||
msgid "Account deletion cancelled"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:151
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Your account (%(username)s) on Baserow (%(public_web_frontend_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:146
|
||||
msgid "Account pending deletion"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:151
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
|
||||
"will be permanently deleted in %(days_left)s days."
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:156
|
||||
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:146
|
||||
#: src/baserow/core/templates/baserow/core/user/reset_password.html:165
|
||||
msgid "Reset password"
|
||||
|
@ -71,7 +114,19 @@ msgstr ""
|
|||
msgid "Reset password - Baserow"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/user/handler.py:162
|
||||
#: src/baserow/core/user/emails.py:37
|
||||
msgid "Account deletion scheduled - Baserow"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/user/emails.py:56
|
||||
msgid "Account permanently deleted - Baserow"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/user/emails.py:74
|
||||
msgid "Account deletion cancelled - Baserow"
|
||||
msgstr ""
|
||||
|
||||
#: src/baserow/core/user/handler.py:166
|
||||
#, python-format
|
||||
msgid "%(name)s's group"
|
||||
msgstr ""
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 3.2.12 on 2022-05-10 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0021_settings_allow_reset_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="to_be_deleted",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"True if the user is pending deletion. An automatic task will "
|
||||
"delete the user after a grace delay."
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 3.2.12 on 2022-05-12 08:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0022_userprofile_deleted"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="settings",
|
||||
name="account_deletion_grace_delay",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=30,
|
||||
help_text=(
|
||||
"Number of days after the last login for an account pending "
|
||||
"deletion to be deleted"
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
|
@ -75,6 +75,13 @@ class Settings(models.Model):
|
|||
default=True,
|
||||
help_text="Indicates whether users can request a password reset link.",
|
||||
)
|
||||
account_deletion_grace_delay = models.PositiveSmallIntegerField(
|
||||
default=30,
|
||||
help_text=(
|
||||
"Number of days after the last login for an account pending deletion "
|
||||
"to be deleted"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
|
@ -91,6 +98,11 @@ class UserProfile(models.Model):
|
|||
help_text="An ISO 639 language code (with optional variant) "
|
||||
"selected by the user. Ex: en-GB.",
|
||||
)
|
||||
to_be_deleted = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if the user is pending deletion. "
|
||||
"An automatic task will delete the user after a grace delay.",
|
||||
)
|
||||
|
||||
|
||||
class Group(TrashableModelMixin, CreatedAndUpdatedOnMixin):
|
||||
|
|
|
@ -7,6 +7,7 @@ from .trash.tasks import (
|
|||
mark_old_trash_for_permanent_deletion,
|
||||
setup_period_trash_tasks,
|
||||
)
|
||||
from .user.tasks import check_pending_account_deletion
|
||||
|
||||
|
||||
@app.task(
|
||||
|
@ -27,4 +28,5 @@ __all__ = [
|
|||
"cleanup_old_actions",
|
||||
"setup_periodic_action_tasks",
|
||||
"sync_templates_task",
|
||||
"check_pending_account_deletion",
|
||||
]
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
{% 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=Open+Sans:400,600" rel="stylesheet" type="text/css">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,600);
|
||||
@import url(https://fonts.googleapis.com/css?family=Montserrat:700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.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: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;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="{{ public_web_frontend_url }}" target="_blank">
|
||||
<img height="auto" src="{{ public_web_frontend_url }}/img/logo.svg" 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></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:Montserrat;font-size:22px;font-weight:700;line-height:1;text-align:left;color:#062E47;">{% trans "Account permanently deleted" %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed with public_web_frontend_hostname as public_web_frontend_hostname %} Your account ({{ username }}) on Baserow ({{ public_web_frontend_hostname }}) has been permanently deleted. {% endblocktrans %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}</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>
|
|
@ -0,0 +1,21 @@
|
|||
<% layout("../../base.layout.eta") %>
|
||||
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text mj-class="title">{% trans "Account permanently deleted" %}</mj-text>
|
||||
<mj-text mj-class="text">
|
||||
{% blocktrans trimmed with public_web_frontend_hostname as public_web_frontend_hostname %}
|
||||
Your account ({{ username }}) on
|
||||
Baserow ({{ public_web_frontend_hostname }}) has been permanently
|
||||
deleted.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-text mj-class="text">
|
||||
{% blocktrans trimmed %}
|
||||
Baserow is an open source no-code database tool which allows you to collaborate
|
||||
on projects, customers and more. It gives you the powers of a developer without
|
||||
leaving your browser.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
|
@ -0,0 +1,172 @@
|
|||
{% 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=Open+Sans:400,600" rel="stylesheet" type="text/css">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,600);
|
||||
@import url(https://fonts.googleapis.com/css?family=Montserrat:700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.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: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;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="{{ public_web_frontend_url }}" target="_blank">
|
||||
<img height="auto" src="{{ public_web_frontend_url }}/img/logo.svg" 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></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:Montserrat;font-size:22px;font-weight:700;line-height:1;text-align:left;color:#062E47;">{% trans "Account deletion cancelled" %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed with user.username as username and public_web_frontend_hostname as public_web_frontend_hostname %} Your account ({{ username }}) on Baserow ({{ public_web_frontend_hostname }}) was pending deletion, but you've logged in so this operation has been cancelled. {% endblocktrans %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}</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>
|
|
@ -0,0 +1,21 @@
|
|||
<% layout("../../base.layout.eta") %>
|
||||
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text mj-class="title">{% trans "Account deletion cancelled" %}</mj-text>
|
||||
<mj-text mj-class="text">
|
||||
{% blocktrans trimmed with user.username as username and public_web_frontend_hostname as public_web_frontend_hostname %}
|
||||
Your account ({{ username }}) on
|
||||
Baserow ({{ public_web_frontend_hostname }}) was pending deletion, but you've
|
||||
logged in so this operation has been cancelled.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-text mj-class="text">
|
||||
{% blocktrans trimmed %}
|
||||
Baserow is an open source no-code database tool which allows you to collaborate
|
||||
on projects, customers and more. It gives you the powers of a developer without
|
||||
leaving your browser.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
|
@ -0,0 +1,177 @@
|
|||
{% 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=Open+Sans:400,600" rel="stylesheet" type="text/css">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,600);
|
||||
@import url(https://fonts.googleapis.com/css?family=Montserrat:700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.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: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;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="{{ public_web_frontend_url }}" target="_blank">
|
||||
<img height="auto" src="{{ public_web_frontend_url }}/img/logo.svg" 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></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:Montserrat;font-size:22px;font-weight:700;line-height:1;text-align:left;color:#062E47;">{% trans "Account pending deletion" %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed with user.username as username and public_web_frontend_hostname as public_web_frontend_hostname %} Your account ({{ username }}) on Baserow ({{ public_web_frontend_hostname }}) will be permanently deleted in {{ days_left }} days. {% endblocktrans %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:20px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed %} If you've changed your mind and want to cancel your account deletion, you just have to login again. {% endblocktrans %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Open Sans;font-size:13px;line-height:170%;text-align:left;color:#062E47;">{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}</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>
|
|
@ -0,0 +1,27 @@
|
|||
<% layout("../../base.layout.eta") %>
|
||||
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text mj-class="title">{% trans "Account pending deletion" %}</mj-text>
|
||||
<mj-text mj-class="text">
|
||||
{% blocktrans trimmed with user.username as username and public_web_frontend_hostname as public_web_frontend_hostname %}
|
||||
Your account ({{ username }}) on
|
||||
Baserow ({{ public_web_frontend_hostname }}) will be permanently
|
||||
deleted in {{ days_left }} days.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-text mj-class="text" padding-bottom="20px">
|
||||
{% blocktrans trimmed %}
|
||||
If you've changed your mind and want to cancel your account deletion,
|
||||
you just have to login again.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-text mj-class="text">
|
||||
{% blocktrans trimmed %}
|
||||
Baserow is an open source no-code database tool which allows you to collaborate
|
||||
on projects, customers and more. It gives you the powers of a developer without
|
||||
leaving your browser.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
|
@ -23,3 +23,59 @@ class ResetPasswordEmail(BaseEmailMessage):
|
|||
expire_hours=settings.RESET_PASSWORD_TOKEN_MAX_AGE / 60 / 60,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class AccountDeletionScheduled(BaseEmailMessage):
|
||||
template_name = "baserow/core/user/account_deletion_scheduled.html"
|
||||
|
||||
def __init__(self, user, days_left, *args, **kwargs):
|
||||
self.days_left = days_left
|
||||
self.user = user
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_subject(self):
|
||||
return _("Account deletion scheduled - Baserow")
|
||||
|
||||
def get_context(self):
|
||||
context = super().get_context()
|
||||
context.update(
|
||||
user=self.user,
|
||||
days_left=self.days_left,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class AccountDeleted(BaseEmailMessage):
|
||||
template_name = "baserow/core/user/account_deleted.html"
|
||||
|
||||
def __init__(self, username, *args, **kwargs):
|
||||
self.username = username
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_subject(self):
|
||||
return _("Account permanently deleted - Baserow")
|
||||
|
||||
def get_context(self):
|
||||
context = super().get_context()
|
||||
context.update(
|
||||
username=self.username,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class AccountDeletionCanceled(BaseEmailMessage):
|
||||
template_name = "baserow/core/user/account_deletion_cancelled.html"
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_subject(self):
|
||||
return _("Account deletion cancelled - Baserow")
|
||||
|
||||
def get_context(self):
|
||||
context = super().get_context()
|
||||
context.update(
|
||||
user=self.user,
|
||||
)
|
||||
return context
|
||||
|
|
|
@ -14,6 +14,10 @@ class InvalidPassword(Exception):
|
|||
"""Raised when the provided password is incorrect."""
|
||||
|
||||
|
||||
class UserIsLastAdmin(Exception):
|
||||
"""Raised when a user wants to delete himself but is the last site wide admin."""
|
||||
|
||||
|
||||
class DisabledSignupError(Exception):
|
||||
"""
|
||||
Raised when a user account is created when the new signup setting is disabled.
|
||||
|
|
|
@ -1,31 +1,42 @@
|
|||
from typing import Optional
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Count
|
||||
from django.db import transaction
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.auth.models import update_last_login, AbstractUser
|
||||
|
||||
from baserow.core.handler import CoreHandler
|
||||
from baserow.core.trash.handler import TrashHandler
|
||||
from baserow.core.registries import plugin_registry
|
||||
from baserow.core.exceptions import BaseURLHostnameNotAllowed
|
||||
from baserow.core.exceptions import GroupInvitationEmailMismatch
|
||||
from baserow.core.models import UserProfile
|
||||
from baserow.core.models import Template, UserProfile, Group, UserLogEntry
|
||||
|
||||
from .exceptions import (
|
||||
UserAlreadyExist,
|
||||
UserIsLastAdmin,
|
||||
UserNotFound,
|
||||
PasswordDoesNotMatchValidation,
|
||||
InvalidPassword,
|
||||
DisabledSignupError,
|
||||
ResetPasswordDisabledError,
|
||||
)
|
||||
from .emails import ResetPasswordEmail
|
||||
from .emails import (
|
||||
ResetPasswordEmail,
|
||||
AccountDeletionScheduled,
|
||||
AccountDeletionCanceled,
|
||||
AccountDeleted,
|
||||
)
|
||||
from .utils import normalize_email_address
|
||||
|
||||
|
||||
|
@ -33,19 +44,18 @@ User = get_user_model()
|
|||
|
||||
|
||||
class UserHandler:
|
||||
def get_user(self, user_id=None, email=None):
|
||||
def get_user(
|
||||
self, user_id: Optional[int] = None, email: Optional[str] = None
|
||||
) -> AbstractUser:
|
||||
"""
|
||||
Finds and returns a single user instance based on the provided parameters.
|
||||
|
||||
:param user_id: The user id of the user.
|
||||
:type user_id: int
|
||||
:param email: The username, which is their email address, of the user.
|
||||
:type email: str
|
||||
:raises ValueError: When neither a `user_id` or `email` has been provided.
|
||||
:raises UserNotFound: When the user with the provided parameters has not been
|
||||
found.
|
||||
:return: The requested user.
|
||||
:rtype: User
|
||||
"""
|
||||
|
||||
if not user_id and not email:
|
||||
|
@ -67,32 +77,26 @@ class UserHandler:
|
|||
|
||||
def create_user(
|
||||
self,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
language=settings.LANGUAGE_CODE,
|
||||
group_invitation_token=None,
|
||||
template=None,
|
||||
):
|
||||
name: str,
|
||||
email: str,
|
||||
password: str,
|
||||
language: str = settings.LANGUAGE_CODE,
|
||||
group_invitation_token: Optional[str] = None,
|
||||
template: Template = None,
|
||||
) -> AbstractUser:
|
||||
"""
|
||||
Creates a new user with the provided information and creates a new group and
|
||||
application for him. If the optional group invitation is provided then the user
|
||||
joins that group without creating a new one.
|
||||
|
||||
:param name: The name of the new user.
|
||||
:type name: str
|
||||
:param email: The e-mail address of the user, this is also the username.
|
||||
:type email: str
|
||||
:param password: The password of the user.
|
||||
:type password: str
|
||||
:param language: The language selected by the user.
|
||||
:type language: str
|
||||
:param group_invitation_token: If provided and valid, the invitation will be
|
||||
accepted and and initial group will not be created.
|
||||
:type group_invitation_token: str
|
||||
:param template: If provided, that template will be installed into the newly
|
||||
created group.
|
||||
:type template: Template
|
||||
:raises: UserAlreadyExist: When a user with the provided username (email)
|
||||
already exists.
|
||||
:raises GroupInvitationEmailMismatch: If the group invitation email does not
|
||||
|
@ -101,7 +105,6 @@ class UserHandler:
|
|||
:raises PasswordDoesNotMatchValidation: When a provided password does not match
|
||||
password validation.
|
||||
:return: The user object.
|
||||
:rtype: User
|
||||
"""
|
||||
|
||||
core_handler = CoreHandler()
|
||||
|
@ -168,22 +171,25 @@ class UserHandler:
|
|||
if not group_invitation_token and template:
|
||||
core_handler.install_template(user, group_user.group, template)
|
||||
|
||||
# Call the user_created method for each plugin that is un the registry.
|
||||
# Call the user_created method for each plugin that is in the registry.
|
||||
for plugin in plugin_registry.registry.values():
|
||||
plugin.user_created(user, group_user.group, group_invitation, template)
|
||||
|
||||
return user
|
||||
|
||||
def update_user(self, user, first_name=None, language=None):
|
||||
def update_user(
|
||||
self,
|
||||
user: AbstractUser,
|
||||
first_name: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> AbstractUser:
|
||||
"""
|
||||
Update user modifiable properties
|
||||
Updates the user's account editable properties
|
||||
|
||||
:param user: The user instance to update.
|
||||
:type user: User
|
||||
:param first_name: The new user first name.
|
||||
:param language: The language selected by the user.
|
||||
:type language: str
|
||||
:return: The user object.
|
||||
:rtype: User
|
||||
"""
|
||||
|
||||
if first_name is not None:
|
||||
|
@ -196,7 +202,7 @@ class UserHandler:
|
|||
|
||||
return user
|
||||
|
||||
def get_reset_password_signer(self):
|
||||
def get_reset_password_signer(self) -> URLSafeTimedSerializer:
|
||||
"""
|
||||
Instantiates the password reset serializer that can dump and load values.
|
||||
|
||||
|
@ -238,23 +244,18 @@ class UserHandler:
|
|||
email = ResetPasswordEmail(user, reset_url, to=[user.email])
|
||||
email.send()
|
||||
|
||||
def reset_password(self, token, password):
|
||||
def reset_password(self, token: str, password: str) -> AbstractUser:
|
||||
"""
|
||||
Changes the password of a user if the provided token is valid.
|
||||
|
||||
:param token: The signed token that was send to the user.
|
||||
:type token: str
|
||||
:param password: The new password of the user.
|
||||
:type password: str
|
||||
:raises: ResetPasswordDisabledError: When resetting passwords is disabled.
|
||||
:raises BadSignature: When the provided token has a bad signature.
|
||||
:raises SignatureExpired: When the provided token's signature has expired.
|
||||
:raises UserNotFound: When a user related to the provided token has not been
|
||||
found.
|
||||
:raises PasswordDoesNotMatchValidation: When a provided password does not match
|
||||
password validation.
|
||||
:return: The updated user instance.
|
||||
:rtype: User
|
||||
"""
|
||||
|
||||
if not CoreHandler().get_settings().allow_reset_password:
|
||||
|
@ -275,24 +276,22 @@ class UserHandler:
|
|||
|
||||
return user
|
||||
|
||||
def change_password(self, user, old_password, new_password):
|
||||
def change_password(
|
||||
self, user: AbstractUser, old_password: str, new_password: str
|
||||
) -> AbstractUser:
|
||||
"""
|
||||
Changes the password of the provided user if the old password matches the
|
||||
existing one.
|
||||
|
||||
:param user: The user for which the password needs to be changed.
|
||||
:type user: User
|
||||
:param old_password: The old password of the user. This must match with the
|
||||
existing password else the InvalidPassword exception is raised.
|
||||
:type old_password: str
|
||||
:param new_password: The new password of the user. After changing the user
|
||||
can only authenticate with this password.
|
||||
:type new_password: str
|
||||
:raises InvalidPassword: When the provided old password is incorrect.
|
||||
:raises PasswordDoesNotMatchValidation: When a provided password does not match
|
||||
password validation.
|
||||
:return: The changed user instance.
|
||||
:rtype: User
|
||||
"""
|
||||
|
||||
if not user.check_password(old_password):
|
||||
|
@ -307,3 +306,141 @@ class UserHandler:
|
|||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
def user_signed_in(self, user: AbstractUser):
|
||||
"""
|
||||
Executes tasks and informs plugins when a user signs in.
|
||||
|
||||
:param user: The user that has just signed in.
|
||||
"""
|
||||
|
||||
if user.profile.to_be_deleted:
|
||||
self.cancel_user_deletion(user)
|
||||
|
||||
update_last_login(None, user)
|
||||
UserLogEntry.objects.create(actor=user, action="SIGNED_IN")
|
||||
|
||||
# Call the user_signed_in method for each plugin that is in the registry to
|
||||
# notify all plugins that a user has signed in.
|
||||
from baserow.core.registries import plugin_registry
|
||||
|
||||
for plugin in plugin_registry.registry.values():
|
||||
plugin.user_signed_in(user)
|
||||
|
||||
def schedule_user_deletion(self, user: AbstractUser, password: str):
|
||||
"""
|
||||
Schedules the user account deletion. The user is flagged as `to_be_deleted` and
|
||||
will be deleted after a predefined grace delay unless the user
|
||||
cancel his account deletion by log in again.
|
||||
To be valid, the current user password must be provided.
|
||||
This action sends an email to the user to explain the proccess.
|
||||
|
||||
:param user: The user to flag as `to_be_deleted`.
|
||||
:param password: The current user password.
|
||||
:raises InvalidPassword: When a provided password is incorrect.
|
||||
"""
|
||||
|
||||
if not user.check_password(password):
|
||||
raise InvalidPassword("The provided password is incorrect.")
|
||||
|
||||
if (
|
||||
user.is_staff
|
||||
and not User.objects.filter(is_staff=True).exclude(pk=user.pk).exists()
|
||||
):
|
||||
raise UserIsLastAdmin("You are the last admin of the instance.")
|
||||
|
||||
user.profile.to_be_deleted = True
|
||||
user.profile.save()
|
||||
|
||||
# update last login to be more accurate
|
||||
update_last_login(None, user)
|
||||
|
||||
core_settings = CoreHandler().get_settings()
|
||||
|
||||
days_left = getattr(
|
||||
settings,
|
||||
"FORCE_ACCOUNT_DELETION_GRACE_DELAY",
|
||||
timedelta(days=core_settings.account_deletion_grace_delay),
|
||||
).days
|
||||
|
||||
with translation.override(user.profile.language):
|
||||
email = AccountDeletionScheduled(user, days_left, to=[user.email])
|
||||
email.send()
|
||||
|
||||
def cancel_user_deletion(self, user: AbstractUser):
|
||||
"""
|
||||
Cancels a previously scheduled user account deletion. This action send an email
|
||||
to the user to confirm the cancelation.
|
||||
|
||||
:param user: The user currently in pending deletion.
|
||||
"""
|
||||
|
||||
user.profile.to_be_deleted = False
|
||||
user.profile.save()
|
||||
|
||||
with translation.override(user.profile.language):
|
||||
email = AccountDeletionCanceled(user, to=[user.email])
|
||||
email.send()
|
||||
|
||||
def delete_expired_users(self, grace_delay: Optional[timedelta] = None):
|
||||
"""
|
||||
Executes all previously scheduled user account deletions for which
|
||||
the `last_login` date is earlier than the defined grace delay. If the users
|
||||
are the last admin of some groups, these groups are also deleted. An email
|
||||
is sent to confirm the user account deletion. This task is periodically
|
||||
executed.
|
||||
|
||||
:param grace_delay: A timedelta that indicate the delay before permanently
|
||||
delete a user account. If this parameter is not given, the delay is defined
|
||||
in the core Baserow settings.
|
||||
"""
|
||||
|
||||
if not grace_delay:
|
||||
core_settings = CoreHandler().get_settings()
|
||||
grace_delay = getattr(
|
||||
settings,
|
||||
"FORCE_ACCOUNT_DELETION_GRACE_DELAY",
|
||||
timedelta(days=core_settings.account_deletion_grace_delay),
|
||||
)
|
||||
|
||||
limit_date = timezone.now() - grace_delay
|
||||
|
||||
users_to_delete = User.objects.filter(
|
||||
profile__to_be_deleted=True, last_login__lt=limit_date
|
||||
)
|
||||
|
||||
deleted_user_info = [
|
||||
(u.username, u.email, u.profile.language) for u in users_to_delete.all()
|
||||
]
|
||||
|
||||
# A group need to be deleted if there was an admin before and there is no
|
||||
# *active* admin after the users deletion.
|
||||
groups_to_be_deleted = Group.objects.annotate(
|
||||
admin_count_after=Count(
|
||||
"groupuser",
|
||||
filter=(
|
||||
Q(groupuser__permissions="ADMIN")
|
||||
& ~Q(
|
||||
groupuser__user__in=User.objects.filter(
|
||||
(
|
||||
Q(profile__to_be_deleted=True)
|
||||
& Q(last_login__lt=limit_date)
|
||||
)
|
||||
| Q(is_active=False)
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
).filter(template=None, admin_count_after=0)
|
||||
|
||||
with transaction.atomic():
|
||||
for group in groups_to_be_deleted:
|
||||
# Here we use the trash handler to be sure that we delete every thing
|
||||
# related the the groups like
|
||||
TrashHandler.permanently_delete(group)
|
||||
users_to_delete.delete()
|
||||
|
||||
for (username, email, language) in deleted_user_info:
|
||||
with translation.override(language):
|
||||
email = AccountDeleted(username, to=[email])
|
||||
email.send()
|
||||
|
|
29
backend/src/baserow/core/user/tasks.py
Normal file
29
backend/src/baserow/core/user/tasks.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from baserow.config.celery import app
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@app.task(bind=True, queue="export")
|
||||
def check_pending_account_deletion(self):
|
||||
"""
|
||||
Periodic tasks that delete pending deletion user account that has overcome the
|
||||
grace delay.
|
||||
"""
|
||||
|
||||
from .handler import UserHandler
|
||||
|
||||
UserHandler().delete_expired_users()
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@app.on_after_finalize.connect
|
||||
def setup_periodic_tasks(sender, **kwargs):
|
||||
sender.add_periodic_task(
|
||||
getattr(settings, "CHECK_PENDING_ACCOUNT_DELETION_INTERVAL", timedelta(days=1)),
|
||||
check_pending_account_deletion.s(),
|
||||
)
|
|
@ -49,11 +49,11 @@ class TableFixtures:
|
|||
|
||||
return table, fields, created_rows
|
||||
|
||||
def create_two_linked_tables(self, user=None, **kwargs):
|
||||
def create_two_linked_tables(self, user=None, database=None):
|
||||
if user is None:
|
||||
user = self.create_user()
|
||||
|
||||
if "database" not in kwargs:
|
||||
if not database:
|
||||
database = self.create_database_application(user=user)
|
||||
|
||||
table_a = self.create_database_table(database=database, name="table_a")
|
||||
|
|
|
@ -34,6 +34,7 @@ class UserFixtures:
|
|||
session_id = kwargs.pop("session_id", "default-test-user-session-id")
|
||||
|
||||
profile_data["language"] = kwargs.pop("language", "en")
|
||||
profile_data["to_be_deleted"] = kwargs.pop("to_be_deleted", False)
|
||||
|
||||
user = User(**kwargs)
|
||||
user.set_password(kwargs["password"])
|
||||
|
|
|
@ -110,6 +110,20 @@ def test_token_auth(api_client, data_fixture):
|
|||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert json["non_field_errors"][0] == "User account is disabled."
|
||||
|
||||
# Check that a login cancel user deletion
|
||||
user_to_be_deleted = data_fixture.create_user(
|
||||
email="test3@test.nl", password="password", to_be_deleted=True
|
||||
)
|
||||
response = api_client.post(
|
||||
reverse("api:user:token_auth"),
|
||||
{"username": "test3@test.nl", "password": "password"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
user_to_be_deleted.refresh_from_db()
|
||||
|
||||
assert user_to_be_deleted.profile.to_be_deleted is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_token_refresh(api_client, data_fixture):
|
||||
|
|
|
@ -4,6 +4,7 @@ from unittest.mock import patch
|
|||
from freezegun import freeze_time
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_204_NO_CONTENT,
|
||||
HTTP_201_CREATED,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_404_NOT_FOUND,
|
||||
|
@ -734,3 +735,60 @@ def test_additional_user_data(api_client, data_fixture):
|
|||
response_json = response.json()
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
assert response_json["tmp"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_schedule_user_deletion(client, data_fixture):
|
||||
valid_password = "aValidPassword"
|
||||
invalid_password = "invalidPassword"
|
||||
user, token = data_fixture.create_user_and_token(
|
||||
email="test@localhost", password=valid_password, is_staff=True
|
||||
)
|
||||
response = client.post(
|
||||
reverse("api:user:schedule_account_deletion"),
|
||||
{},
|
||||
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"] == {
|
||||
"password": [{"error": "This field is required.", "code": "required"}]
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
reverse("api:user:schedule_account_deletion"),
|
||||
{"password": invalid_password},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_INVALID_PASSWORD"
|
||||
|
||||
response = client.post(
|
||||
reverse("api:user:schedule_account_deletion"),
|
||||
{"password": valid_password},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_USER_IS_LAST_ADMIN"
|
||||
|
||||
# Creates a new staff user
|
||||
data_fixture.create_user(email="test2@localhost", is_staff=True)
|
||||
|
||||
response = client.post(
|
||||
reverse("api:user:schedule_account_deletion"),
|
||||
{"password": valid_password},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.profile.to_be_deleted is True
|
||||
|
|
|
@ -528,7 +528,7 @@ def test_an_export_job_which_fails_will_be_marked_as_a_failed_job(
|
|||
assert job_which_fails.error == "Failed"
|
||||
table_exporter_registry.unregister("broken")
|
||||
|
||||
# We do not expect an error because canceled errors should be ignored.
|
||||
# We do not expect an error because cancelled errors should be ignored.
|
||||
job_which_fails = handler.create_pending_export_job(
|
||||
user, table, None, {"exporter_type": "cancelled"}
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
@ -7,6 +8,8 @@ from itsdangerous.exc import SignatureExpired, BadSignature
|
|||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.db import connections
|
||||
|
||||
from baserow.contrib.database.models import (
|
||||
Database,
|
||||
|
@ -17,6 +20,7 @@ from baserow.contrib.database.models import (
|
|||
BooleanField,
|
||||
DateField,
|
||||
)
|
||||
from baserow.contrib.database.fields.models import SelectOption
|
||||
from baserow.contrib.database.views.models import GridViewFieldOptions
|
||||
from baserow.core.exceptions import (
|
||||
BaseURLHostnameNotAllowed,
|
||||
|
@ -28,6 +32,7 @@ from baserow.core.models import Group, GroupUser
|
|||
from baserow.core.registries import plugin_registry
|
||||
from baserow.core.user.exceptions import (
|
||||
UserAlreadyExist,
|
||||
UserIsLastAdmin,
|
||||
UserNotFound,
|
||||
PasswordDoesNotMatchValidation,
|
||||
InvalidPassword,
|
||||
|
@ -424,3 +429,161 @@ def test_change_password_invalid_new_password(data_fixture, invalid_password):
|
|||
|
||||
user.refresh_from_db()
|
||||
assert user.check_password(validOldPW)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_schedule_user_deletion(data_fixture, mailoutbox):
|
||||
valid_password = "aValidPassword"
|
||||
invalid_password = "invalidPassword"
|
||||
user = data_fixture.create_user(
|
||||
email="test@localhost", password=valid_password, is_staff=True
|
||||
)
|
||||
handler = UserHandler()
|
||||
|
||||
with pytest.raises(UserIsLastAdmin):
|
||||
handler.schedule_user_deletion(user, valid_password)
|
||||
|
||||
data_fixture.create_user(email="test_admin@localhost", is_staff=True)
|
||||
|
||||
with pytest.raises(InvalidPassword):
|
||||
handler.schedule_user_deletion(user, invalid_password)
|
||||
|
||||
assert len(mailoutbox) == 0
|
||||
|
||||
handler.schedule_user_deletion(user, valid_password)
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.profile.to_be_deleted is True
|
||||
|
||||
assert len(mailoutbox) == 1
|
||||
assert mailoutbox[0].subject == "Account deletion scheduled - Baserow"
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_cancel_user_deletion(data_fixture, mailoutbox):
|
||||
user = data_fixture.create_user(email="test@localhost", to_be_deleted=True)
|
||||
handler = UserHandler()
|
||||
|
||||
handler.cancel_user_deletion(user)
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.profile.to_be_deleted is False
|
||||
|
||||
assert len(mailoutbox) == 1
|
||||
assert mailoutbox[0].subject == "Account deletion cancelled - Baserow"
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=False)
|
||||
def test_delete_expired_user(
|
||||
data_fixture, mailoutbox, django_capture_on_commit_callbacks
|
||||
):
|
||||
user1 = data_fixture.create_user(email="test1@localhost", to_be_deleted=True)
|
||||
user2 = data_fixture.create_user(email="test2@localhost", to_be_deleted=True)
|
||||
user3 = data_fixture.create_user(email="test3@localhost")
|
||||
user4 = data_fixture.create_user(email="test4@localhost")
|
||||
user5 = data_fixture.create_user(email="test5@localhost", to_be_deleted=True)
|
||||
user6 = data_fixture.create_user(email="test6@localhost", is_active=False)
|
||||
|
||||
connection = connections["default"]
|
||||
initial_table_names = sorted(connection.introspection.table_names())
|
||||
|
||||
database = data_fixture.create_database_application(user=user1)
|
||||
# The link field and the many to many table should be deleted at the end
|
||||
table, table2, link_field = data_fixture.create_two_linked_tables(
|
||||
user=user1, database=database
|
||||
)
|
||||
|
||||
model_a = table.get_model()
|
||||
row_a_1 = model_a.objects.create()
|
||||
row_a_2 = model_a.objects.create()
|
||||
|
||||
model_b = table2.get_model()
|
||||
row_b_1 = model_b.objects.create()
|
||||
row_b_2 = model_b.objects.create()
|
||||
|
||||
getattr(row_a_1, f"field_{link_field.id}").set([row_b_1.id, row_b_2.id])
|
||||
getattr(row_a_2, f"field_{link_field.id}").set([row_b_2.id])
|
||||
|
||||
# Create a multiple select field with option (creates an extra table that should
|
||||
# be deleted at the end)
|
||||
multiple_select_field = data_fixture.create_multiple_select_field(table=table)
|
||||
select_option_1 = SelectOption.objects.create(
|
||||
field=multiple_select_field,
|
||||
order=1,
|
||||
value="Option 1",
|
||||
color="blue",
|
||||
)
|
||||
|
||||
# Only one deleted admin
|
||||
groupuser1 = data_fixture.create_user_group(user=user1)
|
||||
|
||||
# With two admins that are going to be deleted and one user
|
||||
groupuser1_2 = data_fixture.create_user_group(user=user1)
|
||||
groupuser5_2 = data_fixture.create_user_group(user=user5, group=groupuser1_2.group)
|
||||
groupuser3 = data_fixture.create_user_group(
|
||||
user=user3, permissions="MEMBER", group=groupuser1_2.group
|
||||
)
|
||||
|
||||
# With two admins but one non active and we delete the other
|
||||
groupuser1_3 = data_fixture.create_user_group(user=user1)
|
||||
groupuser6 = data_fixture.create_user_group(user=user6, group=groupuser1_3.group)
|
||||
|
||||
# Only one non deleted admin
|
||||
groupuser2 = data_fixture.create_user_group(user=user2)
|
||||
|
||||
# Only one admin non deleted and with a deleted user
|
||||
groupuser4 = data_fixture.create_user_group(user=user4)
|
||||
groupuser5 = data_fixture.create_user_group(
|
||||
user=user5, permissions="MEMBER", group=groupuser4.group
|
||||
)
|
||||
|
||||
# One deleted admin with normal user
|
||||
groupuser4_2 = data_fixture.create_user_group(user=user4, permissions="MEMBER")
|
||||
groupuser5_3 = data_fixture.create_user_group(user=user5, group=groupuser4_2.group)
|
||||
|
||||
handler = UserHandler()
|
||||
|
||||
# Last login before max expiration date (should be deleted)
|
||||
with freeze_time("2020-01-01 12:00"):
|
||||
update_last_login(None, user1)
|
||||
update_last_login(None, user3)
|
||||
update_last_login(None, user5)
|
||||
|
||||
# Last login after max expiration date (shouldn't be deleted)
|
||||
with freeze_time("2020-01-05 12:00"):
|
||||
update_last_login(None, user2)
|
||||
update_last_login(None, user4)
|
||||
|
||||
with freeze_time("2020-01-07 12:00"):
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
handler.delete_expired_users(grace_delay=datetime.timedelta(days=3))
|
||||
|
||||
user_ids = User.objects.values_list("pk", flat=True)
|
||||
assert len(user_ids) == 4
|
||||
assert user1.id not in user_ids
|
||||
assert user5.id not in user_ids
|
||||
assert user2.id in user_ids
|
||||
assert user3.id in user_ids
|
||||
assert user4.id in user_ids
|
||||
assert user6.id in user_ids
|
||||
|
||||
group_ids = Group.objects.values_list("pk", flat=True)
|
||||
assert len(group_ids) == 2
|
||||
assert groupuser1.group.id not in group_ids
|
||||
assert groupuser1_2.group.id not in group_ids
|
||||
assert groupuser1_3.group.id not in group_ids
|
||||
assert groupuser2.group.id in group_ids
|
||||
assert groupuser4.group.id in group_ids
|
||||
assert groupuser4_2.group.id not in group_ids
|
||||
|
||||
end_table_names = sorted(connection.introspection.table_names())
|
||||
|
||||
# Check that everything has really been deleted
|
||||
assert Database.objects.count() == 0
|
||||
assert Table.objects.count() == 0
|
||||
assert SelectOption.objects.count() == 0
|
||||
assert initial_table_names == end_table_names
|
||||
|
||||
# Check mail sent
|
||||
assert len(mailoutbox) == 2
|
||||
assert mailoutbox[0].subject == "Account permanently deleted - Baserow"
|
||||
|
|
|
@ -12,6 +12,7 @@ For example:
|
|||
### New Features
|
||||
|
||||
* Added prefill query parameters for forms. [#852](https://gitlab.com/bramw/baserow/-/issues/852)
|
||||
* Added possibility to delete own user account [#880](https://gitlab.com/bramw/baserow/-/issues/880)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
@ -58,7 +59,7 @@ For example:
|
|||
* Added new environment variable BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB
|
||||
* Fix aggregation not updated on filter update
|
||||
|
||||
## Released (2022-05-10 1.10.0)
|
||||
## Released (2022-10-05 1.10.0)
|
||||
|
||||
* Added batch create/update/delete rows endpoints. These endpoints make it possible to
|
||||
modify multiple rows at once. Currently, row created, row updated, and row deleted
|
||||
|
|
|
@ -9,7 +9,9 @@ User = get_user_model()
|
|||
|
||||
|
||||
class RowCommentSerializer(serializers.ModelSerializer):
|
||||
first_name = serializers.CharField(max_length=32, source="user.first_name")
|
||||
first_name = serializers.CharField(
|
||||
max_length=32, source="user.first_name", required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RowComment
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 3.2.12 on 2022-05-10 14:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("baserow_premium", "0004_kanbanview_card_cover_image_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="rowcomment",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
help_text="The user who made the comment.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -21,7 +21,10 @@ class RowComment(CreatedAndUpdatedOnMixin, models.Model):
|
|||
help_text="The id of the row the comment is for."
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, help_text="The user who made the comment."
|
||||
User,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="The user who made the comment.",
|
||||
)
|
||||
comment = models.TextField(help_text="The users comment.")
|
||||
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
>
|
||||
<div class="row-comments__comment-head">
|
||||
<div class="row-comments__comment-head-initial">
|
||||
{{ comment.first_name | nameAbbreviation }}
|
||||
{{ firstName | nameAbbreviation }}
|
||||
</div>
|
||||
<div class="row-comments__comment-head-details">
|
||||
<div class="row-comments__comment-head-name">
|
||||
{{ ownComment ? $t('rowComment.you') : comment.first_name }}
|
||||
{{ ownComment ? $t('rowComment.you') : firstName }}
|
||||
</div>
|
||||
<div :title="localTimestamp" class="row-comments__comment-head-time">
|
||||
{{ timeAgo }}
|
||||
|
@ -37,6 +37,12 @@ export default {
|
|||
...mapGetters({
|
||||
userId: 'auth/getUserId',
|
||||
}),
|
||||
firstName() {
|
||||
if (this.comment.user_id === null) {
|
||||
return this.$t('rowComment.anonymous')
|
||||
}
|
||||
return this.comment.first_name
|
||||
},
|
||||
ownComment() {
|
||||
return this.comment.user_id === this.userId
|
||||
},
|
||||
|
|
|
@ -27,7 +27,8 @@
|
|||
"comment": "Comment"
|
||||
},
|
||||
"rowComment": {
|
||||
"you": "You"
|
||||
"you": "You",
|
||||
"anonymous": "Anonymous"
|
||||
},
|
||||
"registerLicenseModal": {
|
||||
"titleRegisterLicense": "Register a license",
|
||||
|
|
|
@ -39,7 +39,8 @@
|
|||
"settingType": {
|
||||
"account": "Account",
|
||||
"password": "Password",
|
||||
"tokens": "API Tokens"
|
||||
"tokens": "API Tokens",
|
||||
"deleteAccount": "Delete account"
|
||||
},
|
||||
"userFileUploadType": {
|
||||
"file": "my device",
|
||||
|
|
|
@ -44,5 +44,4 @@
|
|||
|
||||
.admin-settings__control {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
border: solid 1px $color-neutral-200;
|
||||
border-radius: 3px;
|
||||
padding: 31px 16px 16px 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.delete-section__label {
|
||||
|
@ -32,8 +33,8 @@
|
|||
}
|
||||
|
||||
.delete-section__description {
|
||||
color: $color-primary-900;
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.delete-section__list {
|
||||
|
|
|
@ -26,3 +26,9 @@
|
|||
@include loading(14px);
|
||||
@include absolute(50%, auto, auto, 50%);
|
||||
}
|
||||
|
||||
.loading__wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
}
|
||||
|
|
48
web-frontend/modules/core/components/Alert.vue
Normal file
48
web-frontend/modules/core/components/Alert.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div
|
||||
class="alert"
|
||||
:class="{
|
||||
'alert--simple': simple,
|
||||
[`alert--${type}`]: type,
|
||||
'alert--has-icon': icon,
|
||||
'alert--with-shadow': shadow,
|
||||
}"
|
||||
>
|
||||
<div v-if="icon" class="alert__icon">
|
||||
<i class="fas" :class="`fa-${icon}`" />
|
||||
</div>
|
||||
<div class="alert__title">{{ title }}</div>
|
||||
<p class="alert__content"><slot /></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
type: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
simple: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shadow: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
icon: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,235 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2 class="box__title">{{ $t('deleteAccountSettings.title') }}</h2>
|
||||
|
||||
<p>
|
||||
{{
|
||||
$t('deleteAccountSettings.description', {
|
||||
days: settings.account_deletion_grace_delay,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div v-if="$fetchState.pending" class="loading__wrapper">
|
||||
<div class="loading loading-absolute-center" />
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
v-else-if="$fetchState.error"
|
||||
type="error"
|
||||
icon="exclamation"
|
||||
:title="$t('deleteAccountSettings.groupLoadingError')"
|
||||
>
|
||||
{{ $t('deleteAccountSettings.groupLoadingErrorDescription') }}
|
||||
</Alert>
|
||||
|
||||
<div v-else-if="orphanGroups.length" class="delete-section">
|
||||
<div class="delete-section__label">
|
||||
<div class="delete-section__label-icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
{{ $t('deleteAccountSettings.orphanGroups') }}
|
||||
</div>
|
||||
<p class="delete-section__description">
|
||||
{{ $t('deleteAccountSettings.groupNoticeDescription') }}
|
||||
</p>
|
||||
<ul class="delete-section__list">
|
||||
<li v-for="group in orphanGroups" :key="group.id">
|
||||
<i class="delete-section__list-icon fas fa-users"></i>
|
||||
{{ group.name }}
|
||||
<small>
|
||||
{{
|
||||
$tc(
|
||||
'deleteAccountSettings.orphanGroupMemberCount',
|
||||
groupMembers[group.id].length,
|
||||
{
|
||||
count: groupMembers[group.id].length,
|
||||
}
|
||||
)
|
||||
}}</small
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Error :error="error"></Error>
|
||||
|
||||
<form
|
||||
v-if="!success"
|
||||
class="delete-account-settings__form"
|
||||
@submit.prevent="deleteAccount"
|
||||
>
|
||||
<div class="control">
|
||||
<label class="control__label">{{
|
||||
$t('deleteAccountSettings.password')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<PasswordInput
|
||||
v-model="account.password"
|
||||
:validation-state="$v.account.password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">{{
|
||||
$t('deleteAccountSettings.passwordConfirm')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.passwordConfirm"
|
||||
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.account.passwordConfirm.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.passwordConfirm.$error" class="error">
|
||||
{{ $t('deleteAccountSettings.repeatPasswordMatchError') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions actions--right">
|
||||
<button
|
||||
:class="{ 'button--loading': loading }"
|
||||
class="button button--large button--error"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ $t('deleteAccountSettings.submitButton') }}
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { sameAs } from 'vuelidate/lib/validators'
|
||||
|
||||
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import AuthService from '@baserow/modules/core/services/auth'
|
||||
import PasswordInput from '@baserow/modules/core/components/helpers/PasswordInput'
|
||||
import { passwordValidation } from '@baserow/modules/core/validators'
|
||||
import GroupService from '@baserow/modules/core/services/group'
|
||||
|
||||
export default {
|
||||
components: { PasswordInput },
|
||||
mixins: [error],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
success: false,
|
||||
account: {
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
},
|
||||
groupMembers: {},
|
||||
}
|
||||
},
|
||||
async fetch() {
|
||||
this.groupMembers = Object.fromEntries(
|
||||
await Promise.all(
|
||||
this.sortedGroups
|
||||
.filter(({ permissions }) => permissions === 'ADMIN')
|
||||
.map(async ({ id: groupId }) => {
|
||||
const { data } = await GroupService(this.$client).fetchAllUsers(
|
||||
groupId
|
||||
)
|
||||
return [
|
||||
groupId,
|
||||
data.filter(({ user_id: userId }) => userId !== this.userId),
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
userId: 'auth/getUserId',
|
||||
settings: 'settings/get',
|
||||
sortedGroups: 'group/getAllSorted',
|
||||
}),
|
||||
orphanGroups() {
|
||||
return this.sortedGroups.filter(
|
||||
({ id: groupId }) =>
|
||||
this.groupMembers[groupId] &&
|
||||
this.groupMembers[groupId].every(
|
||||
({ permissions }) => permissions !== 'ADMIN'
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async logoff() {
|
||||
await this.$store.dispatch('auth/logoff')
|
||||
this.$nuxt.$router.push({ name: 'login' })
|
||||
this.$store.dispatch('notification/success', {
|
||||
title: this.$t('deleteAccountSettings.accountDeletedSuccessTitle'),
|
||||
message: this.$t('deleteAccountSettings.accountDeletedSuccessMessage'),
|
||||
})
|
||||
},
|
||||
async loadGroupMembers() {
|
||||
this.groupLoading = true
|
||||
try {
|
||||
this.groupMembers = Object.fromEntries(
|
||||
await Promise.all(
|
||||
this.sortedGroups
|
||||
.filter(({ permissions }) => permissions === 'ADMIN')
|
||||
.map(async ({ id: groupId }) => {
|
||||
const { data } = await GroupService(this.$client).fetchAllUsers(
|
||||
groupId
|
||||
)
|
||||
return [
|
||||
groupId,
|
||||
data.filter(({ user_id: userId }) => userId !== this.userId),
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
notifyIf(error, 'group')
|
||||
} finally {
|
||||
this.groupLoading = false
|
||||
}
|
||||
},
|
||||
async deleteAccount() {
|
||||
this.$v.$touch()
|
||||
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.hideError()
|
||||
|
||||
try {
|
||||
await AuthService(this.$client).deleteAccount(this.account.password)
|
||||
this.success = true
|
||||
this.logoff()
|
||||
} catch (error) {
|
||||
this.handleError(error, 'deleteAccount', {
|
||||
ERROR_INVALID_PASSWORD: new ResponseErrorMessage(
|
||||
this.$t('deleteAccountSettings.errorInvalidPasswordTitle'),
|
||||
this.$t('deleteAccountSettings.errorInvalidPasswordMessage')
|
||||
),
|
||||
ERROR_USER_IS_LAST_ADMIN: new ResponseErrorMessage(
|
||||
this.$t('deleteAccountSettings.errorUserIsLastAdminTitle'),
|
||||
this.$t('deleteAccountSettings.errorUserIsLastAdminMessage')
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
account: {
|
||||
passwordConfirm: {
|
||||
sameAsPassword: sameAs('password'),
|
||||
},
|
||||
password: passwordValidation,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -49,7 +49,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
registeredSettings() {
|
||||
return this.$registry.getAll('settings')
|
||||
return this.$registry.getOrderedList('settings')
|
||||
},
|
||||
settingPageComponent() {
|
||||
const active = Object.values(this.$registry.getAll('settings')).find(
|
||||
|
|
|
@ -47,6 +47,26 @@
|
|||
"errorInvalidOldPasswordTitle": "Invalid password",
|
||||
"errorInvalidOldPasswordMessage": "Could not change your password because your old password is invalid."
|
||||
},
|
||||
"deleteAccountSettings": {
|
||||
"title": "Delete account",
|
||||
"description": "You can schedule the deletion of your account by entering your current password and clicking the button. Your account will be permanently deleted after {days} days. In the meantime, if you log in again, your account deletion will be cancelled.",
|
||||
"groupNotice": "Orphan groups will be deleted",
|
||||
"groupNoticeDescription": "When your account is permanently deleted, all groups and associated data for which you are the last active user with Admin permissions will also be deleted. The groups shown below are the ones that will be deleted because you are the only admin. To prevent them being deleted you must first give another user admin before deleting your account.",
|
||||
"orphanGroups": "Will also be permanently deleted after the grace time",
|
||||
"orphanGroupMemberCount": "shared with nobody|shared with one user|shared with {count} users",
|
||||
"password": "Password",
|
||||
"passwordConfirm": "Repeat password",
|
||||
"repeatPasswordMatchError": "This field must match the first password field.",
|
||||
"submitButton": "Delete account",
|
||||
"errorInvalidPasswordTitle": "Invalid password",
|
||||
"errorInvalidPasswordMessage": "Could not delete your account because your password is invalid.",
|
||||
"errorUserIsLastAdminTitle": "Last administrator",
|
||||
"errorUserIsLastAdminMessage": "Could not delete your account because your are the last administrator of this Baserow instance.",
|
||||
"accountDeletedSuccessTitle": "Account deletion scheduled",
|
||||
"accountDeletedSuccessMessage": "Your account has been scheduled to be deleted.",
|
||||
"groupLoadingError": "Groups checking has failed",
|
||||
"groupLoadingErrorDescription": "Checking the groups to be deleted failed, please refresh the page."
|
||||
},
|
||||
"error": {
|
||||
"alreadyExistsTitle": "User already exists",
|
||||
"alreadyExistsMessage": "A user with the provided e-mail address already exists.",
|
||||
|
@ -277,6 +297,10 @@
|
|||
"settingAllowNewAccountsDescription": "By default, any user visiting your Baserow domain can sign up for a new account.",
|
||||
"settingAllowSignupsViaGroupInvitationsName": "Allow signups via group invitations",
|
||||
"settingAllowSignupsViaGroupInvitationDescription": "Even if the creation of new accounts is disabled, this option permits directly invited users to still create an account.",
|
||||
"userDeletionGraceDelay": "User deletion",
|
||||
"settingUserDeletionGraceDelay": "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"
|
||||
},
|
||||
"formSidebar": {
|
||||
|
|
|
@ -92,6 +92,35 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-settings__group">
|
||||
<h2 class="admin-settings__group-title">
|
||||
{{ $t('settings.userDeletionGraceDelay') }}
|
||||
</h2>
|
||||
<div class="admin-settings__item">
|
||||
<div class="admin-settings__label">
|
||||
<div class="admin-settings__name">
|
||||
{{ $t('settings.settingUserDeletionGraceDelay') }}
|
||||
</div>
|
||||
<div class="admin-settings__description">
|
||||
{{ $t('settings.settingUserDeletionGraceDelayDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-settings__control">
|
||||
<input
|
||||
v-model="account_deletion_grace_delay"
|
||||
:class="{
|
||||
'input--error': $v.account_deletion_grace_delay.$error,
|
||||
}"
|
||||
type="number"
|
||||
class="input"
|
||||
@input="$v.account_deletion_grace_delay.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account_deletion_grace_delay.$error" class="error">
|
||||
{{ $t('settings.invalidAccountDeletionGraceDelay') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -103,6 +132,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 { required, integer, between } from 'vuelidate/lib/validators'
|
||||
|
||||
export default {
|
||||
layout: 'app',
|
||||
middleware: 'staff',
|
||||
|
@ -110,13 +141,32 @@ export default {
|
|||
const { data } = await SettingsService(app.$client).getInstanceID()
|
||||
return { instanceId: data.instance_id }
|
||||
},
|
||||
data() {
|
||||
return { account_deletion_grace_delay: null }
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
settings: 'settings/get',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
'settings.account_deletion_grace_delay'(value) {
|
||||
this.account_deletion_grace_delay = value
|
||||
},
|
||||
account_deletion_grace_delay(value) {
|
||||
this.updateSettings({ account_deletion_grace_delay: value })
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.account_deletion_grace_delay =
|
||||
this.settings.account_deletion_grace_delay
|
||||
},
|
||||
methods: {
|
||||
async updateSettings(values) {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('settings/update', values)
|
||||
} catch (error) {
|
||||
|
@ -127,5 +177,12 @@ export default {
|
|||
copyToClipboard(value)
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
account_deletion_grace_delay: {
|
||||
required,
|
||||
between: between(0, 32000),
|
||||
integer: integer(),
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -326,143 +326,89 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="margin-bottom-3">
|
||||
<div class="alert alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert">
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent lobortis feugiat erat, nec volutpat nulla
|
||||
tincidunt vel. In hac habitasse platea dictumst. Aenean fringilla
|
||||
lacus nunc, non pharetra mauris pulvinar lacinia. Aenean ut sem
|
||||
lacinia, sagittis quam sed, pellentesque orci. Aenean non
|
||||
consequat mi. Nunc laoreet ligula a nunc eleifend, nec accumsan
|
||||
felis euismod.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert alert--success alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert alert--warning alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert alert--error alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert--simple alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert alert--simple">
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent lobortis feugiat erat, nec volutpat nulla
|
||||
tincidunt vel. In hac habitasse platea dictumst. Aenean fringilla
|
||||
lacus nunc, non pharetra mauris pulvinar lacinia. Aenean ut sem
|
||||
lacinia, sagittis quam sed, pellentesque orci. Aenean non
|
||||
consequat mi. Nunc laoreet ligula a nunc eleifend, nec accumsan
|
||||
felis euismod.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert alert--simple alert--success alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="
|
||||
alert
|
||||
alert--simple
|
||||
alert--with-shadow
|
||||
alert--warning
|
||||
alert--has-icon
|
||||
"
|
||||
<Alert title="Notification message" icon="exclamation">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert title="Notification message">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent lobortis feugiat erat, nec volutpat nulla tincidunt
|
||||
vel. In hac habitasse platea dictumst. Aenean fringilla lacus nunc,
|
||||
non pharetra mauris pulvinar lacinia. Aenean ut sem lacinia,
|
||||
sagittis quam sed, pellentesque orci. Aenean non consequat mi. Nunc
|
||||
laoreet ligula a nunc eleifend, nec accumsan felis euismod.
|
||||
</Alert>
|
||||
<Alert title="Notification message" type="success" icon="exclamation">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert title="Notification message" type="warning" icon="exclamation">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert title="Notification message" type="error" icon="exclamation">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert title="Notification message" simple icon="exclamation">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert title="Notification message" simple>
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert
|
||||
title="Notification message"
|
||||
type="success"
|
||||
simple
|
||||
icon="exclamation"
|
||||
>
|
||||
<a href="#" class="alert__close">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="alert alert--simple alert--error alert--has-icon">
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Notification message</div>
|
||||
<p class="alert__content">
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui
|
||||
ligula. Pellentesque feugiat erat vel porttitor euismod. Duis nec
|
||||
viverra urna. Praesent.
|
||||
</p>
|
||||
</div>
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert
|
||||
title="Notification message"
|
||||
type="warning"
|
||||
simple
|
||||
shadow
|
||||
icon="exclamation"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
<Alert
|
||||
title="Notification message"
|
||||
type="error"
|
||||
simple
|
||||
shadow
|
||||
icon="exclamation"
|
||||
>
|
||||
Lorem ipsum dolor sit amet, <a href="#">consectetur</a> adipiscing
|
||||
elit. Sed quis gravida ante. Nulla nec elit dui. Nam nec dui ligula.
|
||||
Pellentesque feugiat erat vel porttitor euismod. Duis nec viverra
|
||||
urna. Praesent.
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<div class="margin-bottom-3 style-guide__buttons">
|
||||
<a class="button">a.button</a>
|
||||
<a class="button disabled">a.button[disabled]</a>
|
||||
|
@ -1045,6 +991,12 @@
|
|||
</div>
|
||||
Will also be permanently deleted
|
||||
</div>
|
||||
<p class="delete-section__description">
|
||||
Mauris dignissim massa ac justo consequat porttitor. Lorem ipsum
|
||||
dolor sit amet, consectetur adipiscing elit. Mauris vel tellus
|
||||
suscipit, gravida libero a, egestas urna. Quisque tellus nisi,
|
||||
consequat et interdum non, posuere sed lacus.
|
||||
</p>
|
||||
<ul class="delete-section__list">
|
||||
<li>
|
||||
<i class="delete-section__list-icon fas fa-database"></i>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Registry } from '@baserow/modules/core/registry'
|
|||
import {
|
||||
AccountSettingsType,
|
||||
PasswordSettingsType,
|
||||
DeleteAccountSettingsType,
|
||||
} from '@baserow/modules/core/settingsTypes'
|
||||
import {
|
||||
UploadFileUserFileUploadType,
|
||||
|
@ -51,6 +52,7 @@ export default (context, inject) => {
|
|||
registry.registerNamespace('userFileUpload')
|
||||
registry.register('settings', new AccountSettingsType(context))
|
||||
registry.register('settings', new PasswordSettingsType(context))
|
||||
registry.register('settings', new DeleteAccountSettingsType(context))
|
||||
registry.register('userFileUpload', new UploadFileUserFileUploadType(context))
|
||||
registry.register(
|
||||
'userFileUpload',
|
||||
|
|
|
@ -15,6 +15,7 @@ import Copied from '@baserow/modules/core/components/Copied'
|
|||
import MarkdownIt from '@baserow/modules/core/components/MarkdownIt'
|
||||
import DownloadLink from '@baserow/modules/core/components/DownloadLink'
|
||||
import FormElement from '@baserow/modules/core/components/FormElement'
|
||||
import Alert from '@baserow/modules/core/components/Alert'
|
||||
|
||||
import lowercase from '@baserow/modules/core/filters/lowercase'
|
||||
import uppercase from '@baserow/modules/core/filters/uppercase'
|
||||
|
@ -36,6 +37,7 @@ Vue.component('DropdownItem', DropdownItem)
|
|||
Vue.component('Checkbox', Checkbox)
|
||||
Vue.component('Radio', Radio)
|
||||
Vue.component('Scrollbars', Scrollbars)
|
||||
Vue.component('Alert', Alert)
|
||||
Vue.component('Error', Error)
|
||||
Vue.component('SwitchInput', SwitchInput)
|
||||
Vue.component('Copied', Copied)
|
||||
|
|
|
@ -62,5 +62,8 @@ export default (client) => {
|
|||
update(values) {
|
||||
return client.patch('/user/account/', values)
|
||||
},
|
||||
deleteAccount(password) {
|
||||
return client.post('/user/schedule-account-deletion/', { password })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Registerable } from '@baserow/modules/core/registry'
|
||||
import PasswordSettings from '@baserow/modules/core/components/settings/PasswordSettings'
|
||||
import AccountSettings from '@baserow/modules/core/components/settings/AccountSettings'
|
||||
import DeleteAccountSettings from '@baserow/modules/core/components/settings/DeleteAccountSettings'
|
||||
|
||||
/**
|
||||
* All settings types will be added to the settings modal.
|
||||
|
@ -58,6 +59,10 @@ export class SettingsType extends Registerable {
|
|||
name: this.getName(),
|
||||
}
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountSettingsType extends SettingsType {
|
||||
|
@ -97,3 +102,26 @@ export class PasswordSettingsType extends SettingsType {
|
|||
return PasswordSettings
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteAccountSettingsType extends SettingsType {
|
||||
static getType() {
|
||||
return 'delete-account'
|
||||
}
|
||||
|
||||
getIconClass() {
|
||||
return 'user-slash'
|
||||
}
|
||||
|
||||
getName() {
|
||||
const { i18n } = this.app
|
||||
return i18n.t('settingType.deleteAccount')
|
||||
}
|
||||
|
||||
getComponent() {
|
||||
return DeleteAccountSettings
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 60
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue