From fe43479b19647a76a33045dc60238debb848113e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Sch=C3=A4fer?= Date: Fri, 22 Jan 2021 16:30:38 +0100 Subject: [PATCH] Password reset link support This also includes a restructure of the admin API usage because it was restructured upstream :). --- snikket_web/admin.py | 57 ++++++++++++-- snikket_web/prosodyclient.py | 63 ++++++++++++--- .../templates/admin_reset_user_password.html | 29 +++++++ snikket_web/templates/admin_users.html | 11 ++- .../translations/de/LC_MESSAGES/messages.po | 76 +++++++++++++------ .../translations/en/LC_MESSAGES/messages.po | 74 ++++++++++++------ 6 files changed, 241 insertions(+), 69 deletions(-) create mode 100644 snikket_web/templates/admin_reset_user_password.html diff --git a/snikket_web/admin.py b/snikket_web/admin.py index 8a0e829..9b9e843 100644 --- a/snikket_web/admin.py +++ b/snikket_web/admin.py @@ -17,6 +17,7 @@ from quart import ( redirect, url_for, request, + abort, ) import flask_wtf @@ -34,6 +35,11 @@ async def index() -> str: return await render_template("admin_home.html") +class PasswordResetLinkPost(flask_wtf.FlaskForm): # type: ignore + action_create = wtforms.StringField() + action_revoke = wtforms.StringField() + + @bp.route("/users") @client.require_admin_session() async def users() -> str: @@ -41,9 +47,11 @@ async def users() -> str: await client.list_users(), key=lambda x: x.localpart ) + reset_form = PasswordResetLinkPost() return await render_template( "admin_users.html", users=users, + reset_form=reset_form, ) @@ -86,6 +94,32 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]: ) +@bp.route("/users/password-reset/-", methods=["POST"]) +@client.require_admin_session() +async def create_password_reset_link() -> typing.Union[str, quart.Response]: + form = PasswordResetLinkPost() + if not form.validate_on_submit(): + abort(400) + + if form.action_create.data: + localpart = form.action_create.data + target_user_info = await client.get_user_by_localpart(localpart) + reset_link = await client.create_password_reset_invite( + localpart=localpart, + ttl=86400, + ) + elif form.action_revoke.data: + await client.delete_invite(form.action_revoke.data) + return redirect(url_for(".users")) + + return await render_template( + "admin_reset_user_password.html", + target_user=target_user_info, + reset_link=reset_link, + form=form, + ) + + class InvitesListForm(flask_wtf.FlaskForm): # type:ignore action_revoke = wtforms.StringField() @@ -115,7 +149,7 @@ class InvitePost(flask_wtf.FlaskForm): # type:ignore ) reusable = wtforms.BooleanField( - _l("Allow multiple uses"), + _l("Invite a group of people"), ) action_create_invite = wtforms.SubmitField( @@ -143,7 +177,11 @@ class InvitePost(flask_wtf.FlaskForm): # type:ignore @client.require_admin_session() async def invitations() -> typing.Union[str, quart.Response]: invites = sorted( - await client.list_invites(), + ( + invite + for invite in await client.list_invites() + if not invite.is_reset + ), key=lambda x: x.created_at, reverse=True, ) @@ -190,11 +228,16 @@ async def create_invite() -> typing.Union[str, quart.Response]: (c.id_, c.name) for c in circles ] if form.validate_on_submit(): - invite = await client.create_invite( - group_ids=form.circles.data, - reusable=form.reusable.data, - ttl=form.lifetime.data, - ) + if form.reusable.data: + invite = await client.create_group_invite( + group_ids=form.circles.data, + ttl=form.lifetime.data, + ) + else: + invite = await client.create_account_invite( + group_ids=form.circles.data, + ttl=form.lifetime.data, + ) return redirect(url_for(".edit_invite", id_=invite.id_)) return await render_template("admin_create_invite.html", invite_form=form) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index 81511c6..f9c01ed 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -74,6 +74,7 @@ class AdminInviteInfo: expires: datetime reusable: bool group_ids: typing.Collection[str] + is_reset: bool @classmethod def from_api_response( @@ -91,6 +92,7 @@ class AdminInviteInfo: landing_page=data.get("landing_page"), group_ids=data.get("groups", []), reusable=data["reusable"], + is_reset=data.get("reset", False), ) @@ -857,22 +859,63 @@ class ProsodyClient: self._raise_error_from_response(resp) @autosession - async def create_invite( + async def create_account_invite( self, - group_ids: typing.Collection[str], - reusable: bool, - ttl: int, *, + group_ids: typing.Collection[str] = [], + restrict_username: typing.Optional[str] = None, + ttl: typing.Optional[int] = None, session: aiohttp.ClientSession, ) -> AdminInviteInfo: - payload = { - "reusable": reusable, - "groups": list(group_ids), - "ttl": ttl, - } + payload: typing.Dict[str, typing.Any] = {} + payload["groups"] = list(group_ids) + if restrict_username is not None: + payload["username"] = restrict_username + if ttl is not None: + payload["ttl"] = ttl async with session.post( - self._admin_v1_endpoint("/invites"), + self._admin_v1_endpoint("/invites/account"), + json=payload) as resp: + self._raise_error_from_response(resp) + return AdminInviteInfo.from_api_response(await resp.json()) + + @autosession + async def create_group_invite( + self, + *, + group_ids: typing.Collection[str] = [], + ttl: typing.Optional[int] = None, + session: aiohttp.ClientSession, + ) -> AdminInviteInfo: + payload: typing.Dict[str, typing.Any] = { + "groups": list(group_ids), + } + if ttl is not None: + payload["ttl"] = ttl + + async with session.post( + self._admin_v1_endpoint("/invites/group"), + json=payload) as resp: + self._raise_error_from_response(resp) + return AdminInviteInfo.from_api_response(await resp.json()) + + @autosession + async def create_password_reset_invite( + self, + *, + localpart: str, + ttl: typing.Optional[int] = None, + session: aiohttp.ClientSession, + ) -> AdminInviteInfo: + payload: typing.Dict[str, typing.Any] = { + "username": localpart, + } + if ttl is not None: + payload["ttl"] = ttl + + async with session.post( + self._admin_v1_endpoint("/invites/reset"), json=payload) as resp: self._raise_error_from_response(resp) return AdminInviteInfo.from_api_response(await resp.json()) diff --git a/snikket_web/templates/admin_reset_user_password.html b/snikket_web/templates/admin_reset_user_password.html new file mode 100644 index 0000000..ea6e0e0 --- /dev/null +++ b/snikket_web/templates/admin_reset_user_password.html @@ -0,0 +1,29 @@ +{% extends "app.html" %} +{% from "library.j2" import showuri, standard_button, custom_form_button %} +{% block head_lead %} +{{ super() }} +{% include "copy-snippet.html" %} +{% endblock %} +{% block content %} +

{% trans %}Password reset{% endtrans %}

+
+{{- form.csrf_token -}} +
+

{% trans user_name=target_user.localpart %}Password reset link for {{ user_name }}{% endtrans %}

+

{% trans %}The following link will allow the user to reset their password on their device, once.{% endtrans %}

+
+
{% trans %}Valid until{% endtrans %}
+
{{ reset_link.expires | format_date }}
+
{% trans %}Link{% endtrans %}
+
{% call showuri(reset_link.landing_page) %}{% endcall %}
+ +
+ {%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%} + {% trans %}Destroy link{% endtrans %} + {%- endcall -%} + {%- call standard_button("back", url_for(".users"), class="primary") -%} + {% trans %}Back{% endtrans %} + {%- endcall -%} +
+
+{% endblock %} diff --git a/snikket_web/templates/admin_users.html b/snikket_web/templates/admin_users.html index d0663b7..404121a 100644 --- a/snikket_web/templates/admin_users.html +++ b/snikket_web/templates/admin_users.html @@ -1,7 +1,9 @@ {% extends "admin_app.html" %} -{% from "library.j2" import action_button, value_or_hint %} +{% from "library.j2" import action_button, value_or_hint, custom_form_button %} {% block content %}

{% trans %}Manage users{% endtrans %}

+
+{{- reset_form.csrf_token -}}
@@ -19,16 +21,21 @@ - {% endfor %}
{% call value_or_hint(user.display_name) %}{% endcall %} {% call value_or_hint(user.email) %}{% endcall %} {% call value_or_hint(user.phone) %}{% endcall %} + {%- call action_button("remove", url_for(".delete_user", localpart=user.localpart), class="secondary") -%} {% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %} {%- endcall -%} {%- call action_button("bug_report", url_for(".debug_user", localpart=user.localpart), class="secondary") -%} {% trans user_name=user.localpart %}Show debug information for {{ user_name }}{% endtrans %} {%- endcall -%} + {%- call custom_form_button("create_link", reset_form.action_create.name, user.localpart, class="secondary", slim=True) -%} + {% trans user_name=user.localpart %}Create password reset link for {{ user_name }}{% endtrans %} + {%- endcall -%} +
+ {% endblock %} diff --git a/snikket_web/translations/de/LC_MESSAGES/messages.po b/snikket_web/translations/de/LC_MESSAGES/messages.po index fbe2722..1f139ca 100644 --- a/snikket_web/translations/de/LC_MESSAGES/messages.po +++ b/snikket_web/translations/de/LC_MESSAGES/messages.po @@ -18,75 +18,75 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.0\n" -#: snikket_web/admin.py:52 +#: snikket_web/admin.py:60 msgid "Delete user permanently" msgstr "Benutzer endgültig löschen" -#: snikket_web/admin.py:95 +#: snikket_web/admin.py:129 msgid "Invite to circle" msgstr "In Gemeinschaft einladen" -#: snikket_web/admin.py:101 +#: snikket_web/admin.py:135 msgid "At least one circle must be selected" msgstr "Mindestens eine Gemeinschaft muss ausgewählt sein" -#: snikket_web/admin.py:106 +#: snikket_web/admin.py:140 msgid "Valid for" msgstr "Gültig für" -#: snikket_web/admin.py:108 +#: snikket_web/admin.py:142 msgid "One hour" msgstr "Eine Stunde" -#: snikket_web/admin.py:109 +#: snikket_web/admin.py:143 msgid "Twelve hours" msgstr "Zwölf Stunden" -#: snikket_web/admin.py:110 +#: snikket_web/admin.py:144 msgid "One day" msgstr "Ein Tag" -#: snikket_web/admin.py:111 +#: snikket_web/admin.py:145 msgid "One week" msgstr "Eine Woche" -#: snikket_web/admin.py:112 +#: snikket_web/admin.py:146 msgid "Four weeks" msgstr "Vier Wochen" -#: snikket_web/admin.py:118 +#: snikket_web/admin.py:152 msgid "Allow multiple uses" msgstr "Mehrfach verwendbar" -#: snikket_web/admin.py:122 +#: snikket_web/admin.py:156 msgid "New invitation link" msgstr "Neuer Einladungslink" -#: snikket_web/admin.py:180 +#: snikket_web/admin.py:214 msgid "Revoke" msgstr "Löschen" -#: snikket_web/admin.py:231 snikket_web/admin.py:274 +#: snikket_web/admin.py:265 snikket_web/admin.py:308 msgid "Name" msgstr "Name" -#: snikket_web/admin.py:235 snikket_web/templates/admin_circles.html:42 +#: snikket_web/admin.py:269 snikket_web/templates/admin_circles.html:42 msgid "Create circle" msgstr "Gemeinschaft gründen" -#: snikket_web/admin.py:279 +#: snikket_web/admin.py:313 msgid "Select user" msgstr "Benutzer auswählen" -#: snikket_web/admin.py:284 snikket_web/user.py:68 +#: snikket_web/admin.py:318 snikket_web/user.py:68 msgid "Apply" msgstr "Übernehmen" -#: snikket_web/admin.py:288 +#: snikket_web/admin.py:322 msgid "Delete circle permanently" msgstr "Gemeinschaft endgültig löschen" -#: snikket_web/admin.py:294 +#: snikket_web/admin.py:328 msgid "Add user" msgstr "Benutzer hinzufügen" @@ -144,7 +144,7 @@ msgstr "Jeder" #: snikket_web/templates/admin_delete_user.html:12 #: snikket_web/templates/admin_delete_user.html:16 -#: snikket_web/templates/admin_users.html:9 snikket_web/user.py:55 +#: snikket_web/templates/admin_users.html:11 snikket_web/user.py:55 msgid "Display name" msgstr "Anzeigename" @@ -244,7 +244,7 @@ msgstr "Mitglieder" #: snikket_web/templates/admin_circles.html:13 #: snikket_web/templates/admin_invites.html:24 -#: snikket_web/templates/admin_users.html:12 +#: snikket_web/templates/admin_users.html:14 msgid "Actions" msgstr "Aktionen" @@ -313,7 +313,7 @@ msgid "Copy complete output" msgstr "Komplette Ausgabe kopieren" #: snikket_web/templates/admin_delete_user.html:4 -#: snikket_web/templates/admin_users.html:24 +#: snikket_web/templates/admin_users.html:26 #, python-format msgid "Delete user %(user_name)s" msgstr "Benutzer %(user_name)s löschen" @@ -328,12 +328,12 @@ msgid "Are you sure you want to delete the following user?" msgstr "Bist du sicher dass du den folgenden Benutzer löschen willst?" #: snikket_web/templates/admin_delete_user.html:10 -#: snikket_web/templates/admin_users.html:8 +#: snikket_web/templates/admin_users.html:10 msgid "Login name" msgstr "Anmeldename" #: snikket_web/templates/admin_delete_user.html:14 -#: snikket_web/templates/admin_users.html:10 +#: snikket_web/templates/admin_users.html:12 msgid "Email address" msgstr "E-Mail-Adresse" @@ -344,6 +344,7 @@ msgstr "Gefahr" #: snikket_web/templates/admin_delete_user.html:23 #: snikket_web/templates/admin_edit_circle.html:15 #: snikket_web/templates/admin_edit_invite.html:45 +#: snikket_web/templates/admin_reset_user_password.html:25 #: snikket_web/templates/user_logout.html:13 #: snikket_web/templates/user_passwd.html:40 #: snikket_web/templates/user_profile.html:25 @@ -404,10 +405,12 @@ msgstr "Einladung anzeigen" #: snikket_web/templates/admin_edit_invite.html:13 #: snikket_web/templates/admin_invites.html:21 +#: snikket_web/templates/admin_reset_user_password.html:15 msgid "Valid until" msgstr "Gültig bis" #: snikket_web/templates/admin_edit_invite.html:15 +#: snikket_web/templates/admin_reset_user_password.html:17 msgid "Link" msgstr "Link" @@ -462,6 +465,7 @@ msgid "User information" msgstr "Benutzerinformationen" #: snikket_web/templates/admin_edit_user.html:25 +#: snikket_web/templates/admin_reset_user_password.html:8 msgid "Password reset" msgstr "Passwort zurücksetzen" @@ -549,15 +553,37 @@ msgstr "Einladung löschen" msgid "Currently, there are no pending invitations." msgstr "Derzeit gibt es keine ausstehenden Einladungen." -#: snikket_web/templates/admin_users.html:11 +#: snikket_web/templates/admin_reset_user_password.html:12 +#, python-format +msgid "Password reset link for %(user_name)s" +msgstr "Link zum Zurücksetzen des Passwortes für %(user_name)s" + +#: snikket_web/templates/admin_reset_user_password.html:13 +msgid "" +"The following link will allow the user to reset their password on their " +"device, once." +msgstr "" +"Der folgende Link erlaubt es, das Passwort für das Nutzerkonto einmalig " +"zurückzusetzen." + +#: snikket_web/templates/admin_reset_user_password.html:22 +msgid "Destroy link" +msgstr "Link zerstören" + +#: snikket_web/templates/admin_users.html:13 msgid "Phone number" msgstr "Telefonnummer" -#: snikket_web/templates/admin_users.html:27 +#: snikket_web/templates/admin_users.html:29 #, python-format msgid "Show debug information for %(user_name)s" msgstr "Debugging-Informationen für %(user_name)s anzeigen" +#: snikket_web/templates/admin_users.html:32 +#, python-format +msgid "Create password reset link for %(user_name)s" +msgstr "Benutzer %(user_name)s löschen" + #: snikket_web/templates/app.html:4 msgid "Snikket Web Portal" msgstr "Snikket Webportal" diff --git a/snikket_web/translations/en/LC_MESSAGES/messages.po b/snikket_web/translations/en/LC_MESSAGES/messages.po index 24c4198..d173f05 100644 --- a/snikket_web/translations/en/LC_MESSAGES/messages.po +++ b/snikket_web/translations/en/LC_MESSAGES/messages.po @@ -18,75 +18,75 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.0\n" -#: snikket_web/admin.py:52 +#: snikket_web/admin.py:60 msgid "Delete user permanently" msgstr "" -#: snikket_web/admin.py:95 +#: snikket_web/admin.py:129 msgid "Invite to circle" msgstr "" -#: snikket_web/admin.py:101 +#: snikket_web/admin.py:135 msgid "At least one circle must be selected" msgstr "" -#: snikket_web/admin.py:106 +#: snikket_web/admin.py:140 msgid "Valid for" msgstr "" -#: snikket_web/admin.py:108 +#: snikket_web/admin.py:142 msgid "One hour" msgstr "" -#: snikket_web/admin.py:109 +#: snikket_web/admin.py:143 msgid "Twelve hours" msgstr "" -#: snikket_web/admin.py:110 +#: snikket_web/admin.py:144 msgid "One day" msgstr "" -#: snikket_web/admin.py:111 +#: snikket_web/admin.py:145 msgid "One week" msgstr "" -#: snikket_web/admin.py:112 +#: snikket_web/admin.py:146 msgid "Four weeks" msgstr "" -#: snikket_web/admin.py:118 +#: snikket_web/admin.py:152 msgid "Allow multiple uses" msgstr "" -#: snikket_web/admin.py:122 +#: snikket_web/admin.py:156 msgid "New invitation link" msgstr "" -#: snikket_web/admin.py:180 +#: snikket_web/admin.py:214 msgid "Revoke" msgstr "" -#: snikket_web/admin.py:231 snikket_web/admin.py:274 +#: snikket_web/admin.py:265 snikket_web/admin.py:308 msgid "Name" msgstr "" -#: snikket_web/admin.py:235 snikket_web/templates/admin_circles.html:42 +#: snikket_web/admin.py:269 snikket_web/templates/admin_circles.html:42 msgid "Create circle" msgstr "" -#: snikket_web/admin.py:279 +#: snikket_web/admin.py:313 msgid "Select user" msgstr "" -#: snikket_web/admin.py:284 snikket_web/user.py:68 +#: snikket_web/admin.py:318 snikket_web/user.py:68 msgid "Apply" msgstr "" -#: snikket_web/admin.py:288 +#: snikket_web/admin.py:322 msgid "Delete circle permanently" msgstr "" -#: snikket_web/admin.py:294 +#: snikket_web/admin.py:328 msgid "Add user" msgstr "" @@ -146,7 +146,7 @@ msgstr "" #: snikket_web/templates/admin_delete_user.html:12 #: snikket_web/templates/admin_delete_user.html:16 -#: snikket_web/templates/admin_users.html:9 snikket_web/user.py:55 +#: snikket_web/templates/admin_users.html:11 snikket_web/user.py:55 msgid "Display name" msgstr "" @@ -233,7 +233,7 @@ msgstr "" #: snikket_web/templates/admin_circles.html:13 #: snikket_web/templates/admin_invites.html:24 -#: snikket_web/templates/admin_users.html:12 +#: snikket_web/templates/admin_users.html:14 msgid "Actions" msgstr "" @@ -298,7 +298,7 @@ msgid "Copy complete output" msgstr "" #: snikket_web/templates/admin_delete_user.html:4 -#: snikket_web/templates/admin_users.html:24 +#: snikket_web/templates/admin_users.html:26 #, python-format msgid "Delete user %(user_name)s" msgstr "" @@ -313,12 +313,12 @@ msgid "Are you sure you want to delete the following user?" msgstr "" #: snikket_web/templates/admin_delete_user.html:10 -#: snikket_web/templates/admin_users.html:8 +#: snikket_web/templates/admin_users.html:10 msgid "Login name" msgstr "" #: snikket_web/templates/admin_delete_user.html:14 -#: snikket_web/templates/admin_users.html:10 +#: snikket_web/templates/admin_users.html:12 #, fuzzy msgid "Email address" msgstr "" @@ -330,6 +330,7 @@ msgstr "" #: snikket_web/templates/admin_delete_user.html:23 #: snikket_web/templates/admin_edit_circle.html:15 #: snikket_web/templates/admin_edit_invite.html:45 +#: snikket_web/templates/admin_reset_user_password.html:25 #: snikket_web/templates/user_logout.html:13 #: snikket_web/templates/user_passwd.html:40 #: snikket_web/templates/user_profile.html:25 @@ -388,10 +389,12 @@ msgstr "" #: snikket_web/templates/admin_edit_invite.html:13 #: snikket_web/templates/admin_invites.html:21 +#: snikket_web/templates/admin_reset_user_password.html:15 msgid "Valid until" msgstr "" #: snikket_web/templates/admin_edit_invite.html:15 +#: snikket_web/templates/admin_reset_user_password.html:17 msgid "Link" msgstr "" @@ -440,6 +443,7 @@ msgid "User information" msgstr "" #: snikket_web/templates/admin_edit_user.html:25 +#: snikket_web/templates/admin_reset_user_password.html:8 #, fuzzy msgid "Password reset" msgstr "" @@ -523,15 +527,35 @@ msgstr "" msgid "Currently, there are no pending invitations." msgstr "" -#: snikket_web/templates/admin_users.html:11 +#: snikket_web/templates/admin_reset_user_password.html:12 +#, fuzzy, python-format +msgid "Password reset link for %(user_name)s" +msgstr "Welcome home, %(user_name)s." + +#: snikket_web/templates/admin_reset_user_password.html:13 +msgid "" +"The following link will allow the user to reset their password on their " +"device, once." +msgstr "" + +#: snikket_web/templates/admin_reset_user_password.html:22 +msgid "Destroy link" +msgstr "" + +#: snikket_web/templates/admin_users.html:13 msgid "Phone number" msgstr "" -#: snikket_web/templates/admin_users.html:27 +#: snikket_web/templates/admin_users.html:29 #, fuzzy, python-format msgid "Show debug information for %(user_name)s" msgstr "Welcome home, %(user_name)s." +#: snikket_web/templates/admin_users.html:32 +#, python-format +msgid "Create password reset link for %(user_name)s" +msgstr "" + #: snikket_web/templates/app.html:4 msgid "Snikket Web Portal" msgstr ""