1
0
Fork 0
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:
Jrmi 2022-06-21 07:48:49 +00:00
parent 3e28c3f15c
commit 64c8e81c96
52 changed files with 1880 additions and 208 deletions

View file

@ -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):

View file

@ -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},
}

View file

@ -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",

View file

@ -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

View file

@ -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"),

View file

@ -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,)

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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",
),
),
]

View file

@ -110,6 +110,7 @@ class CoreHandler:
"allow_new_signups",
"allow_signups_via_group_invitations",
"allow_reset_password",
"account_deletion_grace_delay",
],
settings_instance,
)

View file

@ -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 ""

View file

@ -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."
),
),
),
]

View file

@ -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"
),
),
),
]

View file

@ -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):

View file

@ -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",
]

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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.

View file

@ -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()

View 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(),
)

View file

@ -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")

View file

@ -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"])

View file

@ -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):

View file

@ -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

View file

@ -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"}
)

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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,
),
),
]

View file

@ -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.")

View file

@ -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
},

View file

@ -27,7 +27,8 @@
"comment": "Comment"
},
"rowComment": {
"you": "You"
"you": "You",
"anonymous": "Anonymous"
},
"registerLicenseModal": {
"titleRegisterLicense": "Register a license",

View file

@ -39,7 +39,8 @@
"settingType": {
"account": "Account",
"password": "Password",
"tokens": "API Tokens"
"tokens": "API Tokens",
"deleteAccount": "Delete account"
},
"userFileUploadType": {
"file": "my device",

View file

@ -44,5 +44,4 @@
.admin-settings__control {
min-width: 0;
width: 100%;
}

View file

@ -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 {

View file

@ -26,3 +26,9 @@
@include loading(14px);
@include absolute(50%, auto, auto, 50%);
}
.loading__wrapper {
position: relative;
width: 100%;
height: 14px;
}

View 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>

View file

@ -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>

View file

@ -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(

View file

@ -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": {

View file

@ -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>

View file

@ -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>

View file

@ -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',

View file

@ -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)

View file

@ -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 })
},
}
}

View file

@ -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
}
}