Skip to content

Commit

Permalink
Add form for users to delete their own accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
seanh committed Jun 6, 2024
1 parent be6acf3 commit ba4b656
Show file tree
Hide file tree
Showing 16 changed files with 426 additions and 33 deletions.
15 changes: 15 additions & 0 deletions h/accounts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,21 @@ def validator(self, node, value): # pragma: no cover
raise exc


class DeleteAccountSchema(CSRFSchema):
password = password_node(title=_("Confirm password"))

def validator(self, node, value):
super().validator(node, value)

request = node.bindings["request"]
svc = request.find_service(name="user_password")

if not svc.check_password(request.user, value.get("password")):
exc = colander.Invalid(node)
exc["password"] = _("Wrong password.")
raise exc


class NotificationsSchema(CSRFSchema):
types = (("reply", _("Email me when someone replies to one of my annotations.")),)

Expand Down
9 changes: 7 additions & 2 deletions h/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def configure_environment(config): # pragma: no cover
config.registry[ENVIRONMENT_KEY] = create_environment(base)


def handle_form_submission(request, form, on_success, on_failure):
def handle_form_submission(request, form, on_success, on_failure, flash_success=True):
"""
Handle the submission of the given form in a standard way.
Expand All @@ -114,6 +114,11 @@ def handle_form_submission(request, form, on_success, on_failure):
not an XHR request.
:type on_failure: callable
:param flash_success:
Whether to show a "success" flash message if handling the form succeeds.
Applies to non-XHR form submissions only.
:type flash_success: bool
"""
try:
appstruct = form.validate(request.POST.items())
Expand All @@ -126,7 +131,7 @@ def handle_form_submission(request, form, on_success, on_failure):
if result is None:
result = httpexceptions.HTTPFound(location=request.url)

if not request.is_xhr: # pragma: no cover
if (not request.is_xhr) and flash_success:
request.session.flash(_("Success. We've saved your changes."), "success")

return to_xhr_response(request, result, form)
Expand Down
2 changes: 2 additions & 0 deletions h/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ def includeme(config): # pylint: disable=too-many-statements
config.add_route("account_profile", "/account/profile")
config.add_route("account_notifications", "/account/settings/notifications")
config.add_route("account_developer", "/account/developer")
config.add_route("account_delete", "/account/delete")
config.add_route("account_deleted", "/account/deleted")
config.add_route("claim_account_legacy", "/claim_account/{token}")
config.add_route("dismiss_sidebar_tutorial", "/app/dismiss_sidebar_tutorial")

Expand Down
6 changes: 6 additions & 0 deletions h/static/styles/components/_btn.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

min-height: 30px;

/* Vertically center text within buttons.
This works even if the element is not a button,
e.g. <a class="btn"> */
display: flex;
align-items: center;

background-color: color.$grey-6;
border: none;
border-radius: 2px;
Expand Down
16 changes: 8 additions & 8 deletions h/static/styles/components/_form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,6 @@
padding-right: 15px;
}

// A form footer with no top border, useful for additional sections at the
// bottom of a footer.
.form-footer--no-border {
@include form-footer;

margin-top: 15px;
}

.form-help-text {
color: color.$grey-5;
}
Expand All @@ -147,3 +139,11 @@
color: color.$brand;
margin-right: 3px;
}

.form-footer__left {
float: left;
}

.form-footer__right {
float: right;
}
12 changes: 4 additions & 8 deletions h/templates/accounts/account.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@
<div class="form-vertical">
{{ email_form }}
{{ password_form }}

<footer class="form-footer">
{% trans %}
If you would like to delete your account, please email us at
<a class="link--footer" href="mailto:support@hypothes.is">support@hypothes.is</a> from your
registered email address, and we'll take it from there.
{% endtrans %}
</footer>
</div>
{% endblock page_content %}

{% block form_footer_right %}
<a class="link--footer" href="{{ request.route_path('account_delete') }}">{% trans %}Delete your account{% endtrans %}</a>
{% endblock %}
48 changes: 48 additions & 0 deletions h/templates/accounts/delete.html.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{% extends "h:templates/layouts/account.html.jinja2" %}

{% set page_route = 'account_delete' %}
{% set page_title = 'Delete your account' %}

{% block tabs %}{% endblock tabs %}

{% block page_content %}
<h1 class="form-header">{% trans %}Delete your account{% endtrans %}</h1>

<div class="form-description">
<p>{% trans %}Are you sure you want to delete your account?{% endtrans %}</p>

<p>
{% set username = request.user.username %}

{% if count == 0 %}
{% trans %}This will delete user <strong>{{ username }}</strong>.{% endtrans %}
{% else %}
{% set oldest_str = oldest.strftime("%B %-d %Y") %}
{% set newest_str = newest.strftime("%B %-d %Y") %}

{% if oldest_str == newest_str %}
{% trans count=count %}
This will delete user <strong>{{ username }}</strong>,
including 1 annotation
from <strong>{{ oldest_str }}</strong>.
{% pluralize count %}
This will delete user <strong>{{ username }}</strong>,
including <strong>{{ count }}</strong> annotations
from <strong>{{ oldest_str }}</strong>.
{% endtrans %}
{% else %}
{% trans %}
This will delete user <strong>{{ username }}</strong>,
including <strong>{{ count }}</strong> annotations
spanning <strong>{{ oldest_str }}</strong>
to <strong>{{ newest_str }}</strong>.
{% endtrans %}
{% endif %}
{% endif %}
</p>

<p>{% trans %}This cannot be undone!{% endtrans %}</p>
</div>

{{ form }}
{% endblock page_content %}
6 changes: 6 additions & 0 deletions h/templates/accounts/deleted.html.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{% extends "h:templates/accounts/base.html.jinja2" %}

{% block page_title %}{% trans %}Account deleted{% endtrans %}{% endblock %}

{% set form_message %}{% trans %}Your account has been deleted.{% endtrans %}{% endset %}
{% set signup_message = "Don't have a Hypothesis account?" %}
11 changes: 7 additions & 4 deletions h/templates/accounts/developer.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@
</button>
{% endif %}
</form>
<footer class="form-footer">
You can learn more about the Hypothesis API at
<a class="link--footer" href="https://h.readthedocs.io/en/latest/api.html">h.readthedocs.io/en/latest/api.html</a>
</footer>
</div>
{% endblock page_content %}

{% block form_footer_left %}
<p>
You can learn more about the Hypothesis API at
<a class="link--footer" href="https://h.readthedocs.io/en/latest/api.html">h.readthedocs.io/en/latest/api.html</a>
</p>
{% endblock %}
4 changes: 4 additions & 0 deletions h/templates/deform/form.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
<button type="button"
class="btn btn--cancel js-form-cancel">Cancel</button>
{% endif %}
{% if field.back_link %}
<a class="btn btn--cancel"
href="{{ field.back_link.href }}">{{ field.back_link.text }}</a>
{% endif %}
<div class="u-stretch"></div>
<div class="form-actions__buttons">
{%- for button in field.buttons -%}
Expand Down
30 changes: 27 additions & 3 deletions h/templates/layouts/account.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
{% block content %}
<div class="content paper">
<div class="form-container">
{% block tabs %}
<nav class="tabs">
<ul>
{% for route, title in nav_pages %}
Expand All @@ -29,12 +30,35 @@
{% endfor %}
</ul>
</nav>
{% endblock tabs %}
{% include "h:templates/includes/flash-messages.html.jinja2" %}
{{ self.page_content() }}

<footer class="form-footer--no-border">
{% include "h:templates/includes/back_link.html.jinja2" %}
</footer>
{% with %}
{% set footer_left %}
{% block form_footer_left %}{% endblock form_footer_left %}
{% include "h:templates/includes/back_link.html.jinja2" %}
{% endset %}

{% set footer_right %}
{% block form_footer_right %}{% endblock form_footer_right %}
{% endset %}

{% if footer_left|trim or footer_right|trim %}
<footer class="form-footer">
{% if footer_left|trim %}
<div class="form-footer__left">
{{ footer_left }}
</div>
{% endif %}
{% if footer_right|trim %}
<div class="form-footer__right">
{{ footer_right }}
</div>
{% endif %}
</footer>
{% endif %}
{% endwith %}
</div>
</div>
{% endblock content %}
Expand Down
85 changes: 85 additions & 0 deletions h/views/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pyramid import httpexceptions, security
from pyramid.exceptions import BadCSRFToken
from pyramid.view import view_config, view_defaults
from sqlalchemy import func, select

from h import accounts, form, i18n, models, session
from h.accounts import schemas
Expand All @@ -18,6 +19,7 @@
PasswordResetEvent,
)
from h.emails import reset_password
from h.models import Annotation
from h.schemas.forms.accounts import (
EditProfileSchema,
ForgotPasswordSchema,
Expand Down Expand Up @@ -608,6 +610,80 @@ def post(self):
return {"token": token.value}


@view_defaults(
route_name="account_delete",
renderer="h:templates/accounts/delete.html.jinja2",
is_authenticated=True,
)
class DeleteController:
def __init__(self, request):
self.request = request

schema = schemas.DeleteAccountSchema().bind(request=self.request)

self.form = self.request.create_form(
schema,
buttons=(deform.Button(_("Delete your account"), css_class="btn--danger"),),
formid="delete",
back_link={
"href": self.request.route_url("account"),
"text": _("Back to safety"),
},
)

@view_config(request_method="GET")
def get(self):
return self.template_data()

@view_config(request_method="POST")
def post(self):
return form.handle_form_submission(
self.request,
self.form,
on_success=self.delete_user,
on_failure=self.template_data,
flash_success=False,
)

def delete_user(self, _appstruct):
self.request.find_service(name="user_delete").delete_user(
self.request.user,
requested_by=self.request.user,
tag=self.request.matched_route.name,
)

return httpexceptions.HTTPFound(
location=self.request.route_url("account_deleted")
)

def template_data(self):
def query(column):
return (
select(column)
.where(Annotation.deleted.is_(False))
.where(Annotation.userid == self.request.authenticated_userid)
)

count = self.request.db.scalar(
query(func.count(Annotation.id)) # pylint:disable=not-callable
)

oldest = self.request.db.scalar(
query(Annotation.created).order_by(Annotation.created)
)

newest = self.request.db.scalar(
query(Annotation.created).order_by(Annotation.created.desc())
)

return {
"count": count,
"oldest": oldest,
"newest": newest,
"form": self.form.render(),
}


# TODO: This can be removed after October 2016, which will be >1 year from the
# date that the last account claim emails were sent out. At this point,
# if we have not done so already, we should remove all unclaimed
Expand All @@ -631,3 +707,12 @@ def dismiss_sidebar_tutorial(request): # pragma: no cover

request.user.sidebar_tutorial_dismissed = True
return ajax_payload(request, {"status": "okay"})


@view_config(
route_name="account_deleted",
request_method="GET",
renderer="h:templates/accounts/deleted.html.jinja2",
)
def account_deleted(_request):
return {}
Loading

0 comments on commit ba4b656

Please sign in to comment.