diff --git a/snikket_web/admin.py b/snikket_web/admin.py index c1ce7d8..fd1bef8 100644 --- a/snikket_web/admin.py +++ b/snikket_web/admin.py @@ -35,7 +35,6 @@ async def index() -> str: class PasswordResetLinkPost(BaseForm): - action_create = wtforms.StringField() action_revoke = wtforms.StringField() @@ -57,6 +56,87 @@ async def users() -> str: ) +_LIMITED_ROLE_NAME = _("Limited") + + +class EditUserForm(BaseForm): + localpart = wtforms.StringField( + _l("Login name"), + ) + + display_name = wtforms.StringField( + _l("Display name"), + ) + + role = wtforms.RadioField( + _l("Access Level"), + choices=[ + # NOTE: enable this only after something has been done which + # actually enforces the described restrictions :). + # ("prosody:restricted", _l("Limited")), + ("prosody:normal", _l("Normal user")), + ("prosody:admin", _l("Administrator")), + ], + ) + + action_save = wtforms.SubmitField( + _l("Update user"), + ) + + action_create_reset = wtforms.SubmitField( + _l("Create password reset link"), + ) + + +@bp.route("/user//", methods=["GET", "POST"]) +@client.require_admin_session() +async def edit_user(localpart: str) -> typing.Union[quart.Response, str]: + target_user_info = await client.get_user_by_localpart(localpart) + + form = EditUserForm() + if form.validate_on_submit(): + if form.action_create_reset.data: + target_user_info = await client.get_user_by_localpart(localpart) + reset_link = await client.create_password_reset_invite( + localpart=localpart, + ttl=86400, + ) + await flash( + _("Password reset link created"), + "success", + ) + return redirect(url_for( + ".user_password_reset_link", + id_=reset_link.id_, + )) + + await client.update_user( + localpart, + display_name=form.display_name.data, + roles=[form.role.data], + ) + + await flash( + _("User information updated."), + "success", + ) + return redirect(url_for(".edit_user", localpart=localpart)) + + elif request.method == "GET": + form.localpart.data = target_user_info.localpart + form.display_name.data = target_user_info.display_name + if target_user_info.roles: + form.role.data = target_user_info.roles[0] + else: + form.role.data = "prosody:normal" + + return await render_template( + "admin_edit_user.html", + target_user=target_user_info, + form=form, + ) + + class DeleteUserForm(BaseForm): action_delete = wtforms.SubmitField( _l("Delete user permanently") @@ -100,36 +180,38 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]: ) -@bp.route("/users/password-reset/-", methods=["POST"]) +@bp.route("/users/password-reset/", methods=["GET", "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, - ) +async def user_password_reset_link( + id_: str, + ) -> typing.Union[str, quart.Response]: + invite_info = await client.get_invite_by_id( + id_, + ) + if invite_info.jid is None: await flash( - _("Password reset link created"), - "success", - ) - elif form.action_revoke.data: - await client.delete_invite(form.action_revoke.data) - await flash( - _("Password reset link deleted"), - "success", + _("Password reset link not found"), + "alert", ) return redirect(url_for(".users")) + localpart = prosodyclient.split_jid(invite_info.jid)[0] + + form = PasswordResetLinkPost() + if form.validate_on_submit(): + if form.action_revoke.data: + await client.delete_invite(id_) + await flash( + _("Password reset link deleted"), + "success", + ) + return redirect(url_for(".edit_user", localpart=localpart)) + abort(400) + return await render_template( "admin_reset_user_password.html", - target_user=target_user_info, - reset_link=reset_link, + localpart=localpart, + reset_link=invite_info, form=form, ) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index d7cc201..68a41a4 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -44,6 +44,15 @@ class AdminUserInfo: display_name: typing.Optional[str] email: typing.Optional[str] phone: typing.Optional[str] + roles: typing.Optional[typing.List[str]] + + @property + def has_admin_role(self) -> bool: + return bool(self.roles and "prosody:admin" in self.roles) + + @property + def has_restricted_role(self) -> bool: + return bool(self.roles and "prosody:restricted" in self.roles) @classmethod def from_api_response( @@ -55,6 +64,7 @@ class AdminUserInfo: display_name=data.get("display_name") or None, email=data.get("email") or None, phone=data.get("phone") or None, + roles=data.get("roles"), ) @@ -858,6 +868,29 @@ class ProsodyClient: self._raise_error_from_response(resp) return AdminUserInfo.from_api_response(await resp.json()) + @autosession + async def update_user( + self, + localpart: str, + *, + display_name: typing.Optional[str], + roles: typing.Optional[typing.Collection[str]], + session: aiohttp.ClientSession, + ) -> None: + payload: typing.Dict[str, typing.Any] = { + "username": localpart, + } + if display_name is not None: + payload["display_name"] = display_name + if roles is not None: + payload["roles"] = list(roles) + + async with session.put( + self._admin_v1_endpoint("/users/{}".format(localpart)), + json=payload, + ) as resp: + self._raise_error_from_response(resp) + @autosession async def get_user_debug_info( self, diff --git a/snikket_web/scss/app.scss b/snikket_web/scss/app.scss index 8612d62..a22386e 100644 --- a/snikket_web/scss/app.scss +++ b/snikket_web/scss/app.scss @@ -354,6 +354,15 @@ div.form.layout-expanded { display: block; } + .radio-button-ext > label > p { + margin-left: 1.75rem; + margin-top: 0; + } + + .radio-button-ext > label .icon { + margin-left: 0.25em; + } + div.select-wrap { display: block; border-bottom: $w-s4 solid $primary-500; diff --git a/snikket_web/static/img/icons.svg b/snikket_web/static/img/icons.svg index 58bdd80..1d241ec 100644 --- a/snikket_web/static/img/icons.svg +++ b/snikket_web/static/img/icons.svg @@ -37,6 +37,11 @@ licensed under the terms of the Apache 2.0 License --> + + + + + diff --git a/snikket_web/templates/admin_delete_user.html b/snikket_web/templates/admin_delete_user.html index 941418c..6e0c645 100644 --- a/snikket_web/templates/admin_delete_user.html +++ b/snikket_web/templates/admin_delete_user.html @@ -16,7 +16,7 @@

{% trans %}The user and their data will be deleted irrevocably, permanently and immediately upon pushing the below button. There is no way back!{% endtrans %}

{% endcall %}
- {%- call standard_button("back", url_for(".index"), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%} + {%- call standard_button("back", url_for(".edit_user", localpart=target_user.localpart), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} {%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
diff --git a/snikket_web/templates/admin_edit_circle.html b/snikket_web/templates/admin_edit_circle.html index ec26fd1..a629343 100644 --- a/snikket_web/templates/admin_edit_circle.html +++ b/snikket_web/templates/admin_edit_circle.html @@ -40,7 +40,7 @@ {%- endif -%}
- {%- call standard_button("back", url_for(".circles"), class="secondary") -%} + {%- call standard_button("back", url_for(".circles"), class="tertiary") -%} {% trans %}Return to circle list{% endtrans %} {%- endcall -%} {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} diff --git a/snikket_web/templates/admin_edit_invite.html b/snikket_web/templates/admin_edit_invite.html index 4da7ea0..2b20998 100644 --- a/snikket_web/templates/admin_edit_invite.html +++ b/snikket_web/templates/admin_edit_invite.html @@ -44,10 +44,10 @@
{{ invite.created_at | format_date }}
- {%- call form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%} - {%- call standard_button("back", url_for(".invitations"), class="primary") %} + {%- call standard_button("back", url_for(".invitations"), class="tertiary") %} {% trans %}Return to invitation list{% endtrans %} {%- endcall %} + {%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%}
diff --git a/snikket_web/templates/admin_edit_user.html b/snikket_web/templates/admin_edit_user.html new file mode 100644 index 0000000..b54d6c1 --- /dev/null +++ b/snikket_web/templates/admin_edit_user.html @@ -0,0 +1,79 @@ +{% extends "admin_app.html" %} +{% from "library.j2" import box, form_button, standard_button, icon %} +{% macro access_level_description(role, caller=None) %} +{%- if role == "prosody:restricted" -%} +{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %} +{%- elif role == "prosody:normal" -%} +{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %} +{%- elif role == "prosody:admin" -%} +{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %} +{%- endif -%} +{% endmacro %} +{% macro access_level_icon(role, caller=None) %} +{%- if role == "prosody:restricted" -%} +{% call icon("lock") %}{% endcall %} +{%- elif role == "prosody:admin" -%} +{% call icon("admin") %}{% endcall %} +{%- endif -%} +{% endmacro %} +{% block content %} +

{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}

+
+ {{ form.csrf_token }} +

{% trans %}Edit user{% endtrans %}

+
+ {{ form.localpart.label }} + {{ form.localpart(readonly="readonly") }} +

{% trans %}The login name cannot be changed.{% endtrans %}

+
+
+ {{ form.display_name.label }} + {{ form.display_name }} +
+

{% trans %}Access Level{% endtrans %}

+

{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}

+
+
{#- -#} + {{ form.role.label.text }} + {%- for level in form.role -%} +
+ {{ level }} +
+ {%- endfor -%} +
+
+
+ {%- call standard_button("back", url_for(".users"), class="tertiary") -%} + {%- trans -%}Return to user list{%- endtrans -%} + {%- endcall -%} + {%- call standard_button("delete", url_for(".delete_user", localpart=target_user.localpart), class="secondary") -%} + {%- trans -%}Delete user{%- endtrans -%} + {%- endcall -%} + {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} +
+
+

{% trans %}Further actions{% endtrans %}

+
+

{% trans %}Reset password{% endtrans %}

+ {{ form.csrf_token }} +

+ {% trans %}If the user has lost their password, you can use the button below to create a special link which allows to change the password of the account, once.{% endtrans %} +

+
+ {%- call form_button("passwd", form.action_create_reset, class="primary") -%}{%- endcall -%} +
+

{% trans %}Debug information{% endtrans %}

+

+ {% trans %}In some cases, extended information about the user account and the connected devices is necessary to troubleshoot issues. The button below reveals this (sensitive) information.{% endtrans %} +

+
+ {%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="primary") -%} + {%- trans -%}Show debug information{%- endtrans -%} + {%- endcall -%} +
+
+{% endblock %} diff --git a/snikket_web/templates/admin_reset_user_password.html b/snikket_web/templates/admin_reset_user_password.html index ac3fc16..3262b94 100644 --- a/snikket_web/templates/admin_reset_user_password.html +++ b/snikket_web/templates/admin_reset_user_password.html @@ -9,7 +9,7 @@
{{- form.csrf_token -}}
-

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

+

{% trans user_name=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 %}
@@ -21,7 +21,7 @@ {%- 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") -%} + {%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%} {% trans %}Back{% endtrans %} {%- endcall -%}
diff --git a/snikket_web/templates/admin_users.html b/snikket_web/templates/admin_users.html index 82dfeec..7c1831b 100644 --- a/snikket_web/templates/admin_users.html +++ b/snikket_web/templates/admin_users.html @@ -1,9 +1,7 @@ {% extends "admin_app.html" %} -{% from "library.j2" import action_button, value_or_hint, custom_form_button %} +{% from "library.j2" import action_button, icon, value_or_hint, custom_form_button %} {% block content %}

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

- -{{- reset_form.csrf_token -}}
@@ -15,17 +13,19 @@ {% for user in users %} - + @@ -33,6 +33,5 @@ {% endfor %}
{{ user.localpart }} + {{- user.localpart -}} + {%- if user.has_admin_role -%} + {% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %} + {%- endif -%} + {%- if user.has_restricted_role -%} + {% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %} + {%- endif -%} + {% call value_or_hint(user.display_name) %}{% endcall %} - {%- call action_button("delete", 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("passwd", reset_form.action_create.name, user.localpart, class="secondary", slim=True) -%} - {% trans user_name=user.localpart %}Create password reset link for {{ user_name }}{% endtrans %} + {%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%} + {% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %} {%- endcall -%}
- {%- include "admin_create_invite_form.html" -%} {% endblock %} diff --git a/snikket_web/templates/user_logout.html b/snikket_web/templates/user_logout.html index a0e3dbd..d845ebe 100644 --- a/snikket_web/templates/user_logout.html +++ b/snikket_web/templates/user_logout.html @@ -6,7 +6,7 @@

{% trans %}Click below to log yourself out of the web portal. This does not affect any other connected devices.{% endtrans %}

{{ form.csrf_token }}
- {%- call standard_button("back", url_for("user.index"), class="secondary") -%} + {%- call standard_button("back", url_for("user.index"), class="tertiary") -%} {% trans %}Back{% endtrans %} {%- endcall -%} {%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%} diff --git a/snikket_web/templates/user_passwd.html b/snikket_web/templates/user_passwd.html index c000495..2a0005d 100644 --- a/snikket_web/templates/user_passwd.html +++ b/snikket_web/templates/user_passwd.html @@ -24,7 +24,7 @@

{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}

- {%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%} + {%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} {%- call custom_form_button("passwd", "", "", class="primary") -%} {% trans %}Change password{% endtrans %} {%- endcall -%} diff --git a/snikket_web/templates/user_profile.html b/snikket_web/templates/user_profile.html index cced91b..dd69ec3 100644 --- a/snikket_web/templates/user_profile.html +++ b/snikket_web/templates/user_profile.html @@ -29,7 +29,7 @@
- {%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%} + {%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}