diff --git a/requirements.txt b/requirements.txt index 9328045..59dbe20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ quart~=0.11 flask-wtf~=0.14 hsluv~=0.0.2 flask-babel~=1.0 +email-validator~=1.1 diff --git a/snikket_web/__init__.py b/snikket_web/__init__.py index cecb0de..aa8dbfb 100644 --- a/snikket_web/__init__.py +++ b/snikket_web/__init__.py @@ -17,6 +17,7 @@ import quart.exceptions from flask_wtf import FlaskForm import wtforms +import flask_babel from flask_babel import Babel, _, lazy_gettext as _l from . import colour, xmpputil @@ -69,7 +70,7 @@ async def login() -> typing.Union[str, quart.Response]: try: await client.login(jid, password) except quart.exceptions.Unauthorized: - form.errors.setdefault("", []).append( + form.password.errors.append( _("Invalid user name or password.") ) else: @@ -166,6 +167,10 @@ def proc() -> typing.Dict[str, typing.Any]: app.template_filter("repr")(repr) +app.template_filter("format_datetime")(flask_babel.format_datetime) +app.template_filter("format_date")(flask_babel.format_date) +app.template_filter("format_time")(flask_babel.format_time) +app.template_filter("format_timedelta")(flask_babel.format_timedelta) @app.template_filter("flatten") @@ -175,8 +180,10 @@ def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable: return a -from .user import user_bp # NOQA +from .user import user_bp # noqa:F401,E402 +from .admin import bp as admin_bp # noqa:F401,E402 app.register_blueprint(user_bp) +app.register_blueprint(admin_bp) logging_config = app.config.get("LOGGING_CONFIG") if logging_config is not None: diff --git a/snikket_web/admin/__init__.py b/snikket_web/admin/__init__.py new file mode 100644 index 0000000..42ae785 --- /dev/null +++ b/snikket_web/admin/__init__.py @@ -0,0 +1,127 @@ +import typing + +from datetime import datetime + +import quart.flask_patch + +import wtforms +import wtforms.fields.html5 + +from quart import (Blueprint, render_template, redirect, url_for) +import flask_wtf + +from flask_babel import lazy_gettext as _l + +from snikket_web.prosodyclient import client + +bp = Blueprint("admin", __name__, url_prefix="/admin") + + +@bp.route("/") +@client.require_admin_session() +async def index() -> str: + user_info = await client.get_user_info() + return await render_template("admin_home.html", user_info=user_info) + + +@bp.route("/users") +@client.require_admin_session() +async def users() -> str: + user_info = await client.get_user_info() + users = sorted( + await client.list_users(), + key=lambda x: x.localpart + ) + return await render_template( + "admin_users.html", + users=users, + user_info=user_info, + ) + + +class DeleteUserForm(flask_wtf.FlaskForm): + action_delete = wtforms.SubmitField( + _l("Delete user permanently") + ) + + +@bp.route("/user//delete", methods=["GET", "POST"]) +@client.require_admin_session() +async def delete_user(localpart: str) -> typing.Union[str, quart.Response]: + user_info = await client.get_user_info() + target_user_info = await client.get_user_by_localpart(localpart) + form = DeleteUserForm() + if form.validate_on_submit(): + if form.action_delete.data: + await client.delete_user_by_localpart(localpart) + return redirect(url_for(".users")) + + return await render_template( + "admin_delete_user.html", + target_user=target_user_info, + user_info=user_info, + form=form, + ) + + +class InvitesListForm(flask_wtf.FlaskForm): + action_revoke = wtforms.StringField() + + action_create_invite = wtforms.SubmitField( + _l("New invitation link") + ) + + +@bp.route("/invitations", methods=["GET", "POST"]) +@client.require_admin_session() +async def invitations() -> typing.Union[str, quart.Response]: + user_info = await client.get_user_info() + invites = sorted( + await client.list_invites(), + key=lambda x: x.created_at + ) + + form = InvitesListForm() + if form.validate_on_submit(): + if form.action_revoke.data: + await client.delete_invite(form.action_revoke.data) + if form.action_create_invite.data: + info = await client.create_invite() + return redirect(url_for(".edit_invite", id_=info.id_)) + return redirect(url_for(".invitations")) + + return await render_template( + "admin_invites.html", + user_info=user_info, + invites=invites, + now=datetime.utcnow(), + form=form, + ) + + +class InviteForm(flask_wtf.FlaskForm): + action_revoke = wtforms.SubmitField( + _l("Revoke") + ) + + +@bp.route("/invitation/", methods=["GET", "POST"]) +@client.require_admin_session() +async def edit_invite(id_: str) -> typing.Union[str, quart.Response]: + user_info = await client.get_user_info() + invite_info = await client.get_invite_by_id(id_) + + form = InviteForm() + if form.validate_on_submit(): + if form.action_revoke.data: + await client.delete_invite(id_) + return redirect(url_for(".invitations")) + return redirect(url_for(".edit_invite", id_=id_)) + + return await render_template( + "admin_edit_invite.html", + user_info=user_info, + invite=invite_info, + now=datetime.utcnow(), + form=form, + ) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index 8797f73..cf30dfe 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -1,3 +1,5 @@ +import dataclasses +import enum import functools import hashlib import logging @@ -5,6 +7,8 @@ import secrets import types import typing +from datetime import datetime + import aiohttp import xml.etree.ElementTree as ET @@ -19,9 +23,71 @@ from . import xmpputil from .xmpputil import split_jid +SCOPE_DEFAULT = "prosody:scope:default" +SCOPE_ADMIN = "prosody:scope:admin" + + T = typing.TypeVar("T") +@dataclasses.dataclass(frozen=True) +class TokenInfo: + token: str + scopes: typing.Collection[str] + + +@dataclasses.dataclass(frozen=True) +class AdminUserInfo: + localpart: str + display_name: typing.Optional[str] + email: typing.Optional[str] + phone: typing.Optional[str] + + @classmethod + def from_api_response( + cls, + data: typing.Mapping[str, typing.Any], + ) -> "AdminUserInfo": + return cls( + localpart=data["username"], + display_name=data.get("display_name") or None, + email=data.get("email") or None, + phone=data.get("phone") or None, + ) + + +class InviteType(enum.Enum): + REGISTER = "register" + + +@dataclasses.dataclass(frozen=True) +class AdminInviteInfo: + id_: str + type_: InviteType + jid: typing.Optional[str] + token: str + xmpp_uri: typing.Optional[str] + landing_page: typing.Optional[str] + created_at: datetime + expires: datetime + + @classmethod + def from_api_response( + cls, + data: typing.Mapping[str, typing.Any], + ) -> "AdminInviteInfo": + return cls( + id_=data["id"], + created_at=datetime.utcfromtimestamp(data["created_at"]), + expires=datetime.utcfromtimestamp(data["expires"]), + type_=InviteType(data["type"]), + jid=data["jid"], + token=data["id"], + xmpp_uri=data.get("xmpp_uri"), + landing_page=data.get("landing_page"), + ) + + class HTTPSessionManager: def __init__(self, app_context_attribute: str): self._app_context_attribute = app_context_attribute @@ -119,6 +185,7 @@ class ProsodyClient: CTX_AUTH_SESSION = "_ProsodyClient__auth_session" CONFIG_ENDPOINT = "PROSODY_ENDPOINT" SESSION_TOKEN = "prosody_access_token" + SESSION_CACHED_SCOPE = "prosody_scope_cache" SESSION_ADDRESS = "prosody_jid" def __init__(self, app: typing.Optional[quart.Quart] = None): @@ -158,19 +225,26 @@ class ProsodyClient: def _rest_endpoint(self) -> str: return "{}/rest".format(self._endpoint_base) + def _admin_v1_endpoint(self, subpath: str) -> str: + return "{}/admin_api{}".format(self._endpoint_base, subpath) + async def _oauth2_bearer_token(self, session: aiohttp.ClientSession, jid: str, - password: str) -> None: + password: str) -> TokenInfo: request = aiohttp.FormData() request.add_field("grant_type", "password") request.add_field("username", jid) request.add_field("password", password) + request.add_field( + "scope", + " ".join([SCOPE_DEFAULT, SCOPE_ADMIN]) + ) self.logger.debug("sending OAuth2 request (payload omitted)") async with session.post(self._login_endpoint, data=request) as resp: auth_status = resp.status - auth_info = (await resp.json()) + auth_info: typing.Mapping[str, str] = (await resp.json()) if auth_status in [400, 401]: self.logger.debug("oauth2 error: %r", auth_info) @@ -189,7 +263,10 @@ class ProsodyClient: auth_info["token_type"] ) ) - return auth_info["access_token"] + return TokenInfo( + token=auth_info["access_token"], + scopes=auth_info["scope"].split(), + ) raise RuntimeError( "unexpected authentication reply: ({}) {!r}".format( @@ -199,10 +276,13 @@ class ProsodyClient: async def login(self, jid: str, password: str) -> bool: async with self._plain_session as session: - token = await self._oauth2_bearer_token(session, jid, password) + token_info = await self._oauth2_bearer_token( + session, jid, password, + ) - http_session[self.SESSION_TOKEN] = token + http_session[self.SESSION_TOKEN] = token_info.token http_session[self.SESSION_ADDRESS] = jid + http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes) return True @property @@ -252,6 +332,30 @@ class ProsodyClient: return wrapped return decorator + def require_admin_session( + self, + redirect_to: typing.Optional[str] = None, + ) -> typing.Callable[ + [typing.Callable[..., typing.Awaitable[T]]], + typing.Callable[..., typing.Awaitable[ + typing.Union[T, quart.Response]]]]: + def decorator( + f: typing.Callable[..., typing.Awaitable[T]], + ) -> typing.Callable[..., typing.Awaitable[ + typing.Union[T, quart.Response]]]: + @functools.wraps(f) + @self.require_session(redirect_to=redirect_to) + async def wrapped( + *args: typing.Any, + **kwargs: typing.Any, + ) -> typing.Union[T, quart.Response]: + if not self.is_admin_session: + raise abort(403, "This is not for you.") + + return await f(*args, **kwargs) + return wrapped + return decorator + async def _xml_iq_call( self, session: aiohttp.ClientSession, @@ -309,6 +413,7 @@ class ProsodyClient: "nickname": nickname, "display_name": nickname or localpart, "avatar_hash": avatar_hash, + "is_admin": self.is_admin_session, } @autosession @@ -450,12 +555,115 @@ class ProsodyClient: # server to expire/revoke all tokens on password change. http_session[self.SESSION_TOKEN] = token + def _raise_error_from_response( + self, + resp: aiohttp.ClientResponse, + ) -> None: + if resp.status in [401, 403]: + abort(403, "request rejected by backend") + if resp.status == 400: + abort(500, "request rejected by backend") + if not 200 <= resp.status < 300: + abort(resp.status) + + @autosession + async def list_users( + self, + *, + session: aiohttp.ClientSession, + ) -> typing.Collection[AdminUserInfo]: + result = [] + async with session.get(self._admin_v1_endpoint("/users")) as resp: + self._raise_error_from_response(resp) + for user in await resp.json(): + result.append(AdminUserInfo.from_api_response(user)) + return result + + @autosession + async def get_user_by_localpart( + self, + localpart: str, + *, + session: aiohttp.ClientSession, + ) -> AdminUserInfo: + async with session.get( + self._admin_v1_endpoint("/users/{}".format(localpart)), + ) as resp: + self._raise_error_from_response(resp) + return AdminUserInfo.from_api_response(await resp.json()) + + @autosession + async def delete_user_by_localpart( + self, + localpart: str, + *, + session: aiohttp.ClientSession, + ) -> None: + async with session.delete( + self._admin_v1_endpoint("/users/{}".format(localpart)), + ) as resp: + self._raise_error_from_response(resp) + + @autosession + async def list_invites( + self, + *, + session: aiohttp.ClientSession, + ) -> typing.Collection[AdminInviteInfo]: + async with session.get(self._admin_v1_endpoint("/invites")) as resp: + self._raise_error_from_response(resp) + return list(map(AdminInviteInfo.from_api_response, + await resp.json())) + + @autosession + async def get_invite_by_id( + self, + id_: str, + *, + session: aiohttp.ClientSession, + ) -> AdminInviteInfo: + async with session.get( + self._admin_v1_endpoint("/invites/{}".format(id_)), + ) as resp: + self._raise_error_from_response(resp) + return AdminInviteInfo.from_api_response(await resp.json()) + + @autosession + async def delete_invite( + self, + id_: str, + *, + session: aiohttp.ClientSession, + ) -> None: + async with session.delete( + self._admin_v1_endpoint("/invites/{}".format(id_)), + ) as resp: + self._raise_error_from_response(resp) + + @autosession + async def create_invite( + self, + *, + session: aiohttp.ClientSession, + ) -> AdminInviteInfo: + async with session.put(self._admin_v1_endpoint("/invites")) as resp: + self._raise_error_from_response(resp) + return AdminInviteInfo.from_api_response(await resp.json()) + async def logout(self) -> None: # this currently only kills the cookie stuff, we may want to invalidate # the token on the server side, toos # See-Also: https://issues.prosody.im/1503 http_session.pop(self.SESSION_TOKEN, None) http_session.pop(self.SESSION_ADDRESS, None) + http_session.pop(self.SESSION_CACHED_SCOPE, None) + + @property + def is_admin_session(self) -> bool: + if not self.has_session: + return False + scopes = http_session[self.SESSION_CACHED_SCOPE].split() + return SCOPE_ADMIN in scopes client = ProsodyClient() diff --git a/snikket_web/scss/_baseline.scss b/snikket_web/scss/_baseline.scss index cfe73b4..618cd0b 100644 --- a/snikket_web/scss/_baseline.scss +++ b/snikket_web/scss/_baseline.scss @@ -7,7 +7,7 @@ body { color: $gray-100; } -p, blockquote, ul, ol { +p, blockquote, ul, ol, table, dl { line-height: 1.5; margin: 1.5em 0; font-family: $font-bulk; @@ -19,6 +19,10 @@ blockquote { margin-right: $w-l2; } +dt { + font-weight: bold; +} + h1, h2, h3, h4, h5, h6 { /* normalise */ font-weight: 400; diff --git a/snikket_web/scss/_theme.scss b/snikket_web/scss/_theme.scss index df64a6f..b86c51f 100644 --- a/snikket_web/scss/_theme.scss +++ b/snikket_web/scss/_theme.scss @@ -59,7 +59,7 @@ $colours: ( $box-types: ( "primary": "blue", "accent": "yellow", - "alert": "alert", + "alert": "red", "warning": "yellow", "success": "green", "hint": "blue" diff --git a/snikket_web/scss/app.scss b/snikket_web/scss/app.scss index 76f6e1b..8be3d38 100644 --- a/snikket_web/scss/app.scss +++ b/snikket_web/scss/app.scss @@ -186,7 +186,7 @@ p.form-desc.weak { color: $gray-300; } -$text-entry-inputs: "text" "password"; +$text-entry-inputs: "text" "password" "email" "tel"; div.f-errbox { background-color: $alert-800; @@ -390,9 +390,30 @@ div.form.layout-expanded { /* form buttons */ -button, .button { +.btn-edit:before { + content: '✎'; +} + +.btn-delete:before { + content: '🗑'; +} + +.btn-link:before { + content: '+'; +} + +.btn-more:before { + content: '…'; +} + +input[type="submit"], button, .button { margin: 0 $w-s2; padding: $w-s3 $w-s1; + + td & { + margin: 0 $w-s4; + padding: $w-s4 $w-s2; + } } a.button { @@ -400,141 +421,145 @@ a.button { cursor: default; } -button.primary, .button.primary { - background: linear-gradient(0deg, $primary-500, $primary-600); - box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); - color: $primary-900; - border: none; - /* TODO: fix vertical rhyhtm ... */ - border-radius: $w-s4; - // border: $w-s4 solid transparent; - - &:hover, &:focus { - background: linear-gradient(0deg, $primary-600, $primary-700); - color: white; - } - - &:active { - background: $primary-500; - box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2); - color: white; - } - - &.accent { - background: linear-gradient(0deg, $accent-500, $accent-600); - color: $accent-900; +input[type="submit"], button, .button { + &.primary { + background: linear-gradient(0deg, $primary-500, $primary-600); + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); + color: $primary-900; + border: none; + /* TODO: fix vertical rhyhtm ... */ + border-radius: $w-s4; + // border: $w-s4 solid transparent; &:hover, &:focus { + background: linear-gradient(0deg, $primary-600, $primary-700); + color: white; + } + + &:active { + background: $primary-500; + box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2); + color: white; + } + + &.accent { + background: linear-gradient(0deg, $accent-500, $accent-600); + color: $accent-900; + + &:hover, &:focus { + background: linear-gradient(0deg, $accent-600, $accent-700); + } + + &:active { + background: $accent-500; + } + } + + &.danger { + background: linear-gradient(0deg, $alert-500, $alert-600); + color: $alert-900; + + &:hover, &:focus { + background: linear-gradient(0deg, $alert-600, $alert-700); + } + + &:active { + background: $alert-500; + } + } + } + + &.secondary { + background: linear-gradient(0deg, $gray-600, $gray-700); + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); + color: $gray-200; + border: none; + /* TODO: fix vertical rhyhtm ... */ + border-radius: $w-s4; + // border: $w-s4 solid transparent; + + &:hover, &:focus { + background: linear-gradient(0deg, $gray-700, $gray-800); + color: black; + } + + &:active { + background: $gray-600; + box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2); + color: black; + } + + &.accent { background: linear-gradient(0deg, $accent-600, $accent-700); + color: $accent-200; + + &:hover, &:focus { + background: linear-gradient(0deg, $accent-700, $accent-800); + } + + &:active { + background: $accent-600; + } } - &:active { - background: $accent-500; - } - } - - &.danger { - background: linear-gradient(0deg, $alert-500, $alert-600); - color: $alert-900; - - &:hover, &:focus { + &.danger { background: linear-gradient(0deg, $alert-600, $alert-700); - } + color: $alert-200; - &:active { - background: $alert-500; - } - } -} + &:hover, &:focus { + background: linear-gradient(0deg, $alert-700, $alert-800); + } -button.secondary, .button.secondary { - background: linear-gradient(0deg, $gray-600, $gray-700); - box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); - color: $gray-200; - border: none; - /* TODO: fix vertical rhyhtm ... */ - border-radius: $w-s4; - // border: $w-s4 solid transparent; - - &:hover, &:focus { - background: linear-gradient(0deg, $gray-700, $gray-800); - color: black; - } - - &:active { - background: $gray-600; - box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2); - color: black; - } - - &.accent { - background: linear-gradient(0deg, $accent-600, $accent-700); - color: $accent-200; - - &:hover, &:focus { - background: linear-gradient(0deg, $accent-700, $accent-800); - } - - &:active { - background: $accent-600; + &:active { + background: $alert-600; + } } } - &.danger { - background: linear-gradient(0deg, $alert-600, $alert-700); - color: $alert-200; - - &:hover, &:focus { - background: linear-gradient(0deg, $alert-700, $alert-800); - } - - &:active { - background: $alert-600; - } - } -} - -button.tertiary, .button.tertiary { - background-color: transparent; - color: $gray-100; - border: none; - text-decoration: underline; - /* TODO: fix vertical rhyhtm ... */ - border-radius: $w-s4; - - &:hover { - background-color: $gray-900; - border-color: $gray-800; - color: black; - } - - &.accent { - text-decoration-color: $accent-500; + &.tertiary, .tertiary { + background-color: transparent; + color: $gray-100; + border: none; + text-decoration: underline; + /* TODO: fix vertical rhyhtm ... */ + border-radius: $w-s4; &:hover { - background-color: $accent-900; - border-color: $accent-800; + background-color: $gray-900; + border-color: $gray-800; color: black; } + + &.accent { + text-decoration-color: $accent-500; + + &:hover { + background-color: $accent-900; + border-color: $accent-800; + color: black; + } + } + + &.danger { + text-decoration-color: $alert-500; + + &:hover { + background-color: $alert-900; + border-color: $alert-800; + color: black; + } + } } - &.danger { - text-decoration-color: $alert-500; - - &:hover { - background-color: $alert-900; - border-color: $alert-800; - color: black; - } + &.fullwidth { + display: block; + width: 100%; + margin-left: 0; + margin-right: 0; } } -button.fullwidth, .button.fullwidth { - display: block; - width: 100%; - margin-left: 0; - margin-right: 0; -} + /* button, .button { margin: 0 $w-s2; @@ -710,7 +735,7 @@ div.welcome-cards { flex-wrap: wrap; & > .card { - flex: 1 0 $w-l6; + flex: 1 0 $w-l7; margin: $w-s1; @extend .el-2; padding: $w-s1 $w-l1; @@ -733,6 +758,24 @@ div.welcome-cards { } +/* admin area specials */ + +#topbar > div.admin-note { + color: $alert-500; + font-size: nth($h-sizes, 5); + margin-left: $w-l1; +} + +table { + border-collapse: collapse; + width: 100%; + + td, th { + padding: $w-s1; + } +} + + /* linearisation / responsive stuff */ @media screen and (max-width: $small-screen-threshold) { @@ -751,4 +794,31 @@ div.welcome-cards { } + th.collapsible, td.collapsible { + display: none; + } + + #topbar.admin { + > header { + text-decoration: underline; + text-decoration-color: $alert-500; + } + + > div.admin-note { + display: none; + } + } +} + +/* clipboard button */ + +.copy-to-clipboard { + cursor: pointer; + margin-left: 0.5em; + font-style: normal; + text-decoration: none; +} + +body.no-copy .copy-to-clipboard { + display: none !important; } diff --git a/snikket_web/templates/admin_app.html b/snikket_web/templates/admin_app.html new file mode 100644 index 0000000..b6d5930 --- /dev/null +++ b/snikket_web/templates/admin_app.html @@ -0,0 +1,6 @@ +{% extends "app.html" %} +{% block topbar_classes %}{{ super() }} admin{% endblock %} +{% block topbar_left %} +{{ super() }} +
Admin area
+{% endblock %} diff --git a/snikket_web/templates/admin_delete_user.html b/snikket_web/templates/admin_delete_user.html new file mode 100644 index 0000000..8bed98a --- /dev/null +++ b/snikket_web/templates/admin_delete_user.html @@ -0,0 +1,27 @@ +{% extends "admin_app.html" %} +{% from "library.j2" import box %} +{% block content %} +

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

+
+

{% trans %}Delete user{% endtrans %}

+ {{ form.csrf_token }} +

{% trans %}Are you sure you want to delete the following user?{% endtrans %}

+
+
{% trans %}Login name{% endtrans %}
+
{{ target_user.localpart }}
+
{% trans %}Display name{% endtrans %}
+
{{ target_user.display_name }}
+
{% trans %}Email address{% endtrans %}
+
{{ target_user.email }}
+
{% trans %}Display name{% endtrans %}
+
{{ target_user.phone }}
+
+ {% call box("alert", _("Danger")) %} +

The user and their data will be deleted irrevocably, permanently and immediately upon pushing thre below button. There is no way back!

+ {% endcall %} +
+ Back + {{ form.action_delete(class="primary danger") }} +
+
+{% endblock %} diff --git a/snikket_web/templates/admin_edit_invite.html b/snikket_web/templates/admin_edit_invite.html new file mode 100644 index 0000000..21aa78a --- /dev/null +++ b/snikket_web/templates/admin_edit_invite.html @@ -0,0 +1,44 @@ +{% extends "admin_app.html" %} +{% block head_lead %} +{{ super() }} +{% include "copy-snippet.html" %} +{% endblock %} + +{% macro clipboard_button(caller=None) -%} +{%- set text = caller() -%} +📋 +{%- endmacro %} + +{% macro showuri(uri, caller=None) %} +{%- if uri is none -%} +— +{%- else -%} +{{ uri }} {% call clipboard_button() %}{{ uri }}{% endcall %} +{%- endif -%} +{% endmacro %} + +{% block content %} +

{% trans %}View invitation{% endtrans %}

+
+{{ form.csrf_token }} +
+
+
Created
+
{{ invite.created_at | format_date }}
+
Valid until
+
{{ invite.expires | format_date }}
+
Landing page
+
{% call showuri(invite.landing_page) %}{% endcall %}
+
XMPP URI
+
{% call showuri(invite.xmpp_uri) %}{% endcall %}
+
+
+ {#- -#} + {{ form.action_revoke(class="button secondary danger") }} + {#- -#} + {% trans %}Back{% endtrans %} + {#- -#} +
+
+
+{% endblock %} diff --git a/snikket_web/templates/admin_edit_user.html b/snikket_web/templates/admin_edit_user.html new file mode 100644 index 0000000..bf05317 --- /dev/null +++ b/snikket_web/templates/admin_edit_user.html @@ -0,0 +1,32 @@ +{% extends "admin_app.html" %} +{% block content %} +

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

+
+

{% trans %}User information{% endtrans %}

+ {{ form.csrf_token }} +
+ {{ form.username.label }} + {{ form.username(readonly="readonly") }} +
+
+ {{ form.nickname.label }} + {{ form.nickname(readonly="readonly") }} +
+
+ {{ form.email.label }} + {{ form.email(readonly="readonly") }} +
+
+ {{ form.phone.label }} + {{ form.phone(readonly="readonly") }} +
+ {{ form.action_save(class="primary") }} + +

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

+

{% trans %}If the user has forgotten their password, use the below button to create a password reset link. The password reset link can be used once to change the password of the account. Transmit the link to the user via a secure channel.{% endtrans %}

+ {{ form.action_create_reset_link(class="secondary accent") }} +

{% trans %}Delete user{% endtrans %}

+

{% trans %}{% endtrans %}

+ {{ form.action_create_reset_link(class="secondary accent") }} +
+{% endblock %} diff --git a/snikket_web/templates/admin_home.html b/snikket_web/templates/admin_home.html new file mode 100644 index 0000000..a4d5d1a --- /dev/null +++ b/snikket_web/templates/admin_home.html @@ -0,0 +1,19 @@ +{% extends "admin_app.html" %} +{% block content %} +

{% trans %}Welcome to the administration dashboard!{% endtrans %}

+

{% trans user_name=user_info.display_name %}At your service, {{ user_name }}.{% endtrans %}

+ +{% endblock %} diff --git a/snikket_web/templates/admin_invites.html b/snikket_web/templates/admin_invites.html new file mode 100644 index 0000000..7b8372b --- /dev/null +++ b/snikket_web/templates/admin_invites.html @@ -0,0 +1,42 @@ +{% extends "admin_app.html" %} +{% block content %} +

{% trans %}Manage invitations{% endtrans %}

+
{{ form.csrf_token }} +
+

{% trans %}Create new invitation{% endtrans %}

+

{% trans %}Create a new invitation link to invite more users to your Snikket instance by clicking the button below.{% endtrans %}

+
+ {{ form.action_create_invite(class="primary") }} +
+
+

{% trans %}Pending invitations{% endtrans %}

+{% if invites %} + + + + + + + + + +{% for invite in invites %} + + + + + +{% endfor %} + +
CreatedExpiresActions
{{ invite.created_at | format_date }}{{ (invite.expires - now) | format_timedelta(add_direction=True) }} + {#- -#} + {% trans %}Show invite details{% endtrans %} + {#- -#} + + {#- -#} +
+{% else %} +

{% trans %}Currently, there are no pending invitations.{% endtrans %}

+{% endif %} +
+{% endblock %} diff --git a/snikket_web/templates/admin_users.html b/snikket_web/templates/admin_users.html new file mode 100644 index 0000000..36da7d4 --- /dev/null +++ b/snikket_web/templates/admin_users.html @@ -0,0 +1,36 @@ +{% extends "admin_app.html" %} +{% macro value_or_hint(v, caller=None) %} +{%- if v is not none -%} +{{- v -}} +{%- else -%} +— +{%- endif -%} +{% endmacro %} +{% block content %} +

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

+ + + + + + + + + + + +{% for user in users %} + + + + + + + +{% endfor %} + +
{% trans %}Login name{% endtrans %}{% trans %}Display name{% endtrans %}{% trans %}Email address{% endtrans %}{% trans %}Phone number{% endtrans %}{% trans %}Actions{% endtrans %}
{{ user.localpart }}{% call value_or_hint(user.display_name) %}{% endcall %}{% call value_or_hint(user.email) %}{% endcall %}{% call value_or_hint(user.phone) %}{% endcall %} + {#- -#}{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %} + {#- -#} +
+{% endblock %} diff --git a/snikket_web/templates/app.html b/snikket_web/templates/app.html index 237a6dc..3600c06 100644 --- a/snikket_web/templates/app.html +++ b/snikket_web/templates/app.html @@ -8,9 +8,11 @@ {{ super() }} {% endblock %} {% block body %} -
-
{{ config["SNIKKET_DOMAIN"] }}
+
+
{{ config["SNIKKET_DOMAIN"] }}
+ {% block topbar_left %}{% endblock %}
+ {% block topbar_right %}{% endblock %}
{% block content %}{% endblock %}
diff --git a/snikket_web/templates/copy-snippet.html b/snikket_web/templates/copy-snippet.html new file mode 100644 index 0000000..ddb7d0d --- /dev/null +++ b/snikket_web/templates/copy-snippet.html @@ -0,0 +1,71 @@ + + diff --git a/snikket_web/templates/user_home.html b/snikket_web/templates/user_home.html index 2723a7a..848870a 100644 --- a/snikket_web/templates/user_home.html +++ b/snikket_web/templates/user_home.html @@ -10,6 +10,12 @@

{% trans %}Change password{% endtrans %}

+ {% if user_info.is_admin %} + +

{% trans %}Admin dashboard{% endtrans %}

+

{% trans %}Manage users and invitations of this Snikket instance.{% endtrans %}

+
+ {% endif %}

{% trans %}Log out{% endtrans %}

{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}

diff --git a/snikket_web/translations/de/LC_MESSAGES/messages.po b/snikket_web/translations/de/LC_MESSAGES/messages.po index 654ecbc..c2bf4e6 100644 --- a/snikket_web/translations/de/LC_MESSAGES/messages.po +++ b/snikket_web/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: SnikketWeb 0.1.0\n" "Report-Msgid-Bugs-To: jonas@zombofant.net\n" -"POT-Creation-Date: 2021-01-16 14:53+0100\n" +"POT-Creation-Date: 2021-01-17 20:09+0100\n" "PO-Revision-Date: 2020-03-07 16:32+0100\n" "Last-Translator: Jonas Schäfer \n" "Language: de\n" @@ -18,20 +18,173 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.0\n" -#: snikket_web/__init__.py:38 +#: snikket_web/__init__.py:40 msgid "Address" msgstr "Adresse" -#: snikket_web/__init__.py:43 +#: snikket_web/__init__.py:45 msgid "Password" msgstr "Passwort" -#: snikket_web/__init__.py:72 -#, fuzzy +#: snikket_web/__init__.py:74 msgid "Invalid user name or password." -msgstr "Neues Passwort (Bestätigung)" +msgstr "Benutzername oder Passwort falsch." -#: snikket_web/templates/app.html:18 snikket_web/templates/login.html:36 +#: snikket_web/admin/__init__.py:44 +msgid "Delete user permanently" +msgstr "Benutzer endgültig löschen" + +#: snikket_web/admin/__init__.py:71 +msgid "New invitation link" +msgstr "Neuer Einladungslink" + +#: snikket_web/admin/__init__.py:104 +msgid "Revoke" +msgstr "Löschen" + +#: snikket_web/templates/admin_delete_user.html:4 +#: snikket_web/templates/admin_users.html:29 +#, python-format +msgid "Delete user %(user_name)s" +msgstr "Benutzer %(user_name)s löschen" + +#: snikket_web/templates/admin_delete_user.html:6 +#: snikket_web/templates/admin_edit_user.html:28 +msgid "Delete user" +msgstr "Benutzer löschen" + +#: snikket_web/templates/admin_delete_user.html:8 +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:14 +msgid "Login name" +msgstr "Anmeldename" + +#: snikket_web/templates/admin_delete_user.html:12 +#: snikket_web/templates/admin_delete_user.html:16 +#: snikket_web/templates/admin_users.html:15 snikket_web/user/__init__.py:48 +msgid "Display name" +msgstr "Anzeigename" + +#: snikket_web/templates/admin_delete_user.html:14 +#: snikket_web/templates/admin_users.html:16 +msgid "Email address" +msgstr "E-Mail-Adresse" + +#: snikket_web/templates/admin_delete_user.html:19 +msgid "Danger" +msgstr "Gefahr" + +#: snikket_web/templates/admin_edit_invite.html:21 +msgid "View invitation" +msgstr "Einladung anzeigen" + +#: snikket_web/templates/admin_edit_invite.html:39 +#: snikket_web/templates/user_passwd.html:39 +#: snikket_web/templates/user_profile.html:18 +msgid "Back" +msgstr "Zurück" + +#: snikket_web/templates/admin_edit_user.html:3 +#, python-format +msgid "Edit user %(user_name)s" +msgstr "Benutzer %(user_name)s bearbeiten" + +#: snikket_web/templates/admin_edit_user.html:5 +msgid "User information" +msgstr "Benutzerinformationen" + +#: snikket_web/templates/admin_edit_user.html:25 +msgid "Password reset" +msgstr "Passwort zurücksetzen" + +#: snikket_web/templates/admin_edit_user.html:26 +msgid "" +"If the user has forgotten their password, use the below button to create " +"a password reset link. The password reset link can be used once to change" +" the password of the account. Transmit the link to the user via a secure " +"channel." +msgstr "" +"Wenn ein Benutzer das Passwort vergessen hat, kannst du den folgenden " +"Button benutzen um einen Link zum Zurücksetzen des Passworts zu erzeugen." +" Dieser Link kann dann ein einziges Mal verwendet werden um das Passwort " +"des Kontos zu ändern. Lasse den Link dem Benutzer auf einem sicheren " +"Übertragungsweg zukommen." + +#: snikket_web/templates/admin_home.html:3 +msgid "Welcome to the administration dashboard!" +msgstr "Willkommen zum Verwaltungsdashboard!" + +#: snikket_web/templates/admin_home.html:4 +#, python-format +msgid "At your service, %(user_name)s." +msgstr "Zu deinen Diensten, %(user_name)s." + +#: snikket_web/templates/admin_home.html:7 +#: snikket_web/templates/admin_users.html:10 +msgid "Manage users" +msgstr "Benutzer verwalten" + +#: snikket_web/templates/admin_home.html:8 +msgid "Modify administrative user information or delete users." +msgstr "Benutzerinformationen verändern oder Benutzer löschen." + +#: snikket_web/templates/admin_home.html:11 +#: snikket_web/templates/admin_invites.html:3 +msgid "Manage invitations" +msgstr "Einladungen verwalten" + +#: snikket_web/templates/admin_home.html:12 +msgid "Create, revoke or view invitations." +msgstr "Einladungen erzeugen, löschen oder anzeigen." + +#: snikket_web/templates/admin_home.html:15 +msgid "Back to the main view" +msgstr "Zurück zur Hauptseite" + +#: snikket_web/templates/admin_home.html:16 +msgid "Go back to your user’s web portal page." +msgstr "Zurück zur Startseite deines Benutzers." + +#: snikket_web/templates/admin_invites.html:6 +msgid "Create new invitation" +msgstr "Neue Einladung erzeugen" + +#: snikket_web/templates/admin_invites.html:7 +msgid "" +"Create a new invitation link to invite more users to your Snikket " +"instance by clicking the button below." +msgstr "" +"Erzeuge eine neue Einladung um mehr Benutzer auf deine Snikket-Instanz " +"einzuladen indem du den folgenden Button klickst." + +#: snikket_web/templates/admin_invites.html:12 +msgid "Pending invitations" +msgstr "Ausstehende Einladungen" + +#: snikket_web/templates/admin_invites.html:29 +msgid "Show invite details" +msgstr "Einladungsdetails anzeigen" + +#: snikket_web/templates/admin_invites.html:31 +msgid "Delete invitation" +msgstr "Einladung löschen" + +#: snikket_web/templates/admin_invites.html:39 +msgid "Currently, there are no pending invitations." +msgstr "Derzeit gibt es keine ausstehenden Einladungen." + +#: snikket_web/templates/admin_users.html:17 +msgid "Phone number" +msgstr "Telefonnummer" + +#: snikket_web/templates/admin_users.html:18 +msgid "Actions" +msgstr "Aktionen" + +#: snikket_web/templates/app.html:20 snikket_web/templates/login.html:36 #, python-format msgid "A
Snikket server" msgstr "Ein Snikket-Server" @@ -46,7 +199,7 @@ msgstr "Gib deine Snikket-Adresse und -Passwort ein um dein Konto zu verwalten." #: snikket_web/templates/login.html:18 msgid "Login failed" -msgstr "" +msgstr "Anmeldung fehlgeschlagen" #: snikket_web/templates/login.html:31 msgid "Log in" @@ -78,11 +231,19 @@ msgstr "" msgid "Change password" msgstr "Passwort ändern" -#: snikket_web/templates/user_home.html:14 +#: snikket_web/templates/user_home.html:15 +msgid "Admin dashboard" +msgstr "Verwaltungsdashboard" + +#: snikket_web/templates/user_home.html:16 +msgid "Manage users and invitations of this Snikket instance." +msgstr "Benutzer und Einladungen von dieser Snikket-Instanz verwalten." + +#: snikket_web/templates/user_home.html:20 msgid "Log out" msgstr "Abmelden" -#: snikket_web/templates/user_home.html:15 +#: snikket_web/templates/user_home.html:21 msgid "Exit the Snikket Web Portal, without logging out your other devices." msgstr "" "Verlasse das Snikket Web-Portal, ohne dass deine anderen Geräte " @@ -115,13 +276,8 @@ msgid "" "After changing your password, you will have to enter the new password on " "all of your devices." msgstr "" -"Nachdem du das Passwort geändert hast, musst du das neue Passwort auf allen" -"Geräten manuell eintragen." - -#: snikket_web/templates/user_passwd.html:39 -#: snikket_web/templates/user_profile.html:18 -msgid "Back" -msgstr "Zurück" +"Nachdem du das Passwort geändert hast, musst du das neue Passwort auf " +"allenGeräten manuell eintragen." #: snikket_web/templates/user_profile.html:7 msgid "Profile" @@ -131,31 +287,30 @@ msgstr "Profil" msgid "Apply" msgstr "Übernehmen" -#: snikket_web/user/__init__.py:21 +#: snikket_web/user/__init__.py:23 msgid "Current password" msgstr "Aktuelles Passwort" -#: snikket_web/user/__init__.py:26 +#: snikket_web/user/__init__.py:28 msgid "New password" msgstr "Neues Passwort" -#: snikket_web/user/__init__.py:31 +#: snikket_web/user/__init__.py:33 msgid "Confirm new password" msgstr "Neues Passwort (Bestätigung)" -#: snikket_web/user/__init__.py:35 +#: snikket_web/user/__init__.py:37 msgid "The new passwords must match." msgstr "Die neuen Passwörter müssen übereinstimmen." -#: snikket_web/user/__init__.py:46 -msgid "Display name" -msgstr "Anzeigename" - -#: snikket_web/user/__init__.py:50 +#: snikket_web/user/__init__.py:52 msgid "Avatar" msgstr "Bild" -#: snikket_web/user/__init__.py:74 +#: snikket_web/user/__init__.py:76 msgid "Incorrect password" msgstr "Ungültiges Passwort" +#~ msgid "none" +#~ msgstr "keiner" + diff --git a/snikket_web/translations/en/LC_MESSAGES/messages.po b/snikket_web/translations/en/LC_MESSAGES/messages.po index 60d2be8..2d4d691 100644 --- a/snikket_web/translations/en/LC_MESSAGES/messages.po +++ b/snikket_web/translations/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-01-16 14:53+0100\n" +"POT-Creation-Date: 2021-01-17 20:09+0100\n" "PO-Revision-Date: 2020-03-07 16:50+0100\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -18,20 +18,169 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.0\n" -#: snikket_web/__init__.py:38 +#: snikket_web/__init__.py:40 msgid "Address" msgstr "Address" -#: snikket_web/__init__.py:43 +#: snikket_web/__init__.py:45 msgid "Password" msgstr "Password" -#: snikket_web/__init__.py:72 +#: snikket_web/__init__.py:74 #, fuzzy msgid "Invalid user name or password." msgstr "Confirm new password" -#: snikket_web/templates/app.html:18 snikket_web/templates/login.html:36 +#: snikket_web/admin/__init__.py:44 +msgid "Delete user permanently" +msgstr "" + +#: snikket_web/admin/__init__.py:71 +msgid "New invitation link" +msgstr "" + +#: snikket_web/admin/__init__.py:104 +msgid "Revoke" +msgstr "" + +#: snikket_web/templates/admin_delete_user.html:4 +#: snikket_web/templates/admin_users.html:29 +#, fuzzy, python-format +msgid "Delete user %(user_name)s" +msgstr "Welcome home, %(user_name)s." + +#: snikket_web/templates/admin_delete_user.html:6 +#: snikket_web/templates/admin_edit_user.html:28 +msgid "Delete user" +msgstr "" + +#: snikket_web/templates/admin_delete_user.html:8 +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:14 +msgid "Login name" +msgstr "" + +#: snikket_web/templates/admin_delete_user.html:12 +#: snikket_web/templates/admin_delete_user.html:16 +#: snikket_web/templates/admin_users.html:15 snikket_web/user/__init__.py:48 +msgid "Display name" +msgstr "Display name" + +#: snikket_web/templates/admin_delete_user.html:14 +#: snikket_web/templates/admin_users.html:16 +#, fuzzy +msgid "Email address" +msgstr "Address" + +#: snikket_web/templates/admin_delete_user.html:19 +msgid "Danger" +msgstr "" + +#: snikket_web/templates/admin_edit_invite.html:21 +msgid "View invitation" +msgstr "" + +#: snikket_web/templates/admin_edit_invite.html:39 +#: snikket_web/templates/user_passwd.html:39 +#: snikket_web/templates/user_profile.html:18 +msgid "Back" +msgstr "Back" + +#: snikket_web/templates/admin_edit_user.html:3 +#, fuzzy, python-format +msgid "Edit user %(user_name)s" +msgstr "Welcome home, %(user_name)s." + +#: snikket_web/templates/admin_edit_user.html:5 +msgid "User information" +msgstr "" + +#: snikket_web/templates/admin_edit_user.html:25 +#, fuzzy +msgid "Password reset" +msgstr "Password" + +#: snikket_web/templates/admin_edit_user.html:26 +msgid "" +"If the user has forgotten their password, use the below button to create " +"a password reset link. The password reset link can be used once to change" +" the password of the account. Transmit the link to the user via a secure " +"channel." +msgstr "" + +#: snikket_web/templates/admin_home.html:3 +msgid "Welcome to the administration dashboard!" +msgstr "" + +#: snikket_web/templates/admin_home.html:4 +#, fuzzy, python-format +msgid "At your service, %(user_name)s." +msgstr "Welcome home, %(user_name)s." + +#: snikket_web/templates/admin_home.html:7 +#: snikket_web/templates/admin_users.html:10 +msgid "Manage users" +msgstr "" + +#: snikket_web/templates/admin_home.html:8 +msgid "Modify administrative user information or delete users." +msgstr "" + +#: snikket_web/templates/admin_home.html:11 +#: snikket_web/templates/admin_invites.html:3 +msgid "Manage invitations" +msgstr "" + +#: snikket_web/templates/admin_home.html:12 +msgid "Create, revoke or view invitations." +msgstr "" + +#: snikket_web/templates/admin_home.html:15 +msgid "Back to the main view" +msgstr "" + +#: snikket_web/templates/admin_home.html:16 +msgid "Go back to your user’s web portal page." +msgstr "" + +#: snikket_web/templates/admin_invites.html:6 +msgid "Create new invitation" +msgstr "" + +#: snikket_web/templates/admin_invites.html:7 +msgid "" +"Create a new invitation link to invite more users to your Snikket " +"instance by clicking the button below." +msgstr "" + +#: snikket_web/templates/admin_invites.html:12 +msgid "Pending invitations" +msgstr "" + +#: snikket_web/templates/admin_invites.html:29 +msgid "Show invite details" +msgstr "" + +#: snikket_web/templates/admin_invites.html:31 +msgid "Delete invitation" +msgstr "" + +#: snikket_web/templates/admin_invites.html:39 +msgid "Currently, there are no pending invitations." +msgstr "" + +#: snikket_web/templates/admin_users.html:17 +msgid "Phone number" +msgstr "" + +#: snikket_web/templates/admin_users.html:18 +msgid "Actions" +msgstr "" + +#: snikket_web/templates/app.html:20 snikket_web/templates/login.html:36 #, python-format msgid "A Snikket server" msgstr "A Snikket server" @@ -78,11 +227,19 @@ msgstr "" msgid "Change password" msgstr "Change password" -#: snikket_web/templates/user_home.html:14 +#: snikket_web/templates/user_home.html:15 +msgid "Admin dashboard" +msgstr "" + +#: snikket_web/templates/user_home.html:16 +msgid "Manage users and invitations of this Snikket instance." +msgstr "" + +#: snikket_web/templates/user_home.html:20 msgid "Log out" msgstr "Log out" -#: snikket_web/templates/user_home.html:15 +#: snikket_web/templates/user_home.html:21 msgid "Exit the Snikket Web Portal, without logging out your other devices." msgstr "Exit the Snikket Web Portal, without logging out your other devices." @@ -116,11 +273,6 @@ msgstr "" "After changing your password, you will have to enter the new password on " "all of your devices." -#: snikket_web/templates/user_passwd.html:39 -#: snikket_web/templates/user_profile.html:18 -msgid "Back" -msgstr "Back" - #: snikket_web/templates/user_profile.html:7 msgid "Profile" msgstr "Profile" @@ -129,31 +281,30 @@ msgstr "Profile" msgid "Apply" msgstr "Apply" -#: snikket_web/user/__init__.py:21 +#: snikket_web/user/__init__.py:23 msgid "Current password" msgstr "Current password" -#: snikket_web/user/__init__.py:26 +#: snikket_web/user/__init__.py:28 msgid "New password" msgstr "New password" -#: snikket_web/user/__init__.py:31 +#: snikket_web/user/__init__.py:33 msgid "Confirm new password" msgstr "Confirm new password" -#: snikket_web/user/__init__.py:35 +#: snikket_web/user/__init__.py:37 msgid "The new passwords must match." msgstr "The new passwords must match." -#: snikket_web/user/__init__.py:46 -msgid "Display name" -msgstr "Display name" - -#: snikket_web/user/__init__.py:50 +#: snikket_web/user/__init__.py:52 msgid "Avatar" msgstr "Avatar" -#: snikket_web/user/__init__.py:74 +#: snikket_web/user/__init__.py:76 msgid "Incorrect password" msgstr "Incorrect password" +#~ msgid "none" +#~ msgstr "" +