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 %}
+
+{% 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 %}
+
{% 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 ""