Compare commits

..

1 Commits

Author SHA1 Message Date
Jonas Schäfer
85536d3748 Fix strange 308 error code when using slash-less invite
That seems to be some Quart-internal redirect which isn’t executed
correctly (probably due to our makeshift error handlers). So I
make this a proper redirect instead.
2021-02-06 15:06:15 +01:00
63 changed files with 1986 additions and 11659 deletions

View File

@@ -27,7 +27,6 @@ jobs:
set -euo pipefail set -euo pipefail
pip install mypy pip install mypy
pip install -r requirements.txt pip install -r requirements.txt
pip install -r build-requirements.txt
- name: Typecheck - name: Typecheck
run: | run: |
python -m mypy --config mypy.ini -p snikket_web python -m mypy --config mypy.ini -p snikket_web
@@ -45,7 +44,7 @@ jobs:
- name: Install - name: Install
run: | run: |
set -euo pipefail set -euo pipefail
pip install flake8 flake8-print pip install flake8
- name: Linting - name: Linting
run: | run: |
python -m flake8 snikket_web python -m flake8 snikket_web

View File

@@ -1,13 +1,7 @@
FROM debian:bullseye-slim AS build FROM debian:buster
RUN set -eu; \ ARG BUILD_SERIES=dev
export DEBIAN_FRONTEND=noninteractive ; \ ARG BUILD_ID=0
apt-get update ; \
apt-get install -y --no-install-recommends \
python3 python3-pip python3-setuptools python3-wheel \
libpython3-dev \
make build-essential \
netcat;
COPY requirements.txt /opt/snikket-web-portal/requirements.txt COPY requirements.txt /opt/snikket-web-portal/requirements.txt
COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt
@@ -17,42 +11,28 @@ COPY babel.cfg /opt/snikket-web-portal/babel.cfg
WORKDIR /opt/snikket-web-portal WORKDIR /opt/snikket-web-portal
RUN pip3 install -r requirements.txt; \
pip3 install -r build-requirements.txt; \
make;
FROM debian:bullseye-slim
ARG BUILD_SERIES=dev
ARG BUILD_ID=0
COPY docker/env.py /etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
HEALTHCHECK CMD nc -zv ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE:-127.0.0.1} ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT:-5765}
RUN set -eu; \ RUN set -eu; \
export DEBIAN_FRONTEND=noninteractive ; \ export DEBIAN_FRONTEND=noninteractive ; \
apt-get update ; \ apt-get update ; \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
python3 python3-pip python3-setuptools python3-wheel; \ python3 python3-pip python3-setuptools python3-wheel \
apt-get clean ; rm -rf /var/lib/apt/lists; \ libpython3-dev \
make build-essential \
; \
pip3 install -r requirements.txt; \
pip3 install -r build-requirements.txt; \
make; \
pip3 uninstall -yr build-requirements.txt; \
apt-get remove -y build-essential make libpython3-dev; \
apt-get autoremove -y; \
pip3 install hypercorn; \ pip3 install hypercorn; \
rm -rf /root/.cache; rm -rf /root/.cache; \
apt-get clean ; rm -rf /var/lib/apt/lists
WORKDIR /opt/snikket-web-portal COPY docker/env.py /etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
COPY requirements.txt /opt/snikket-web-portal/requirements.txt ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
RUN pip3 install -r requirements.txt; rm -rf /root/.cache;
COPY --from=build /opt/snikket-web-portal/snikket_web/ /opt/snikket-web-portal/snikket_web
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
RUN echo "$BUILD_SERIES $BUILD_ID" > /opt/snikket-web-portal/.app_version
ADD docker/entrypoint.sh /entrypoint.sh ADD docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

View File

@@ -1,4 +1,3 @@
pyscss~=1.3 pyscss~=1.3
mypy mypy
python-dotenv~=0.15 python-dotenv~=0.15
types-toml

View File

@@ -2,7 +2,4 @@
export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN" export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN"
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE-127.0.0.1}" exec hypercorn -b "127.0.0.1:5765" 'snikket_web:create_app()'
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT-5765}"
exec hypercorn -b "${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE}:${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT}" 'snikket_web:create_app()'

View File

@@ -1,9 +1,8 @@
aiohttp~=3.6 aiohttp~=3.6
quart~=0.11,<0.15 quart~=0.11
flask-wtf~=0.14 flask-wtf~=0.14
hsluv~=0.0.2 hsluv~=0.0.2
flask-babel~=1.0 flask-babel~=1.0
email-validator~=1.1 email-validator~=1.1
environ-config~=20.0 environ-config~=20.0
wtforms~=2.3
typing-extensions typing-extensions

View File

@@ -21,7 +21,7 @@ from quart import (
import environ import environ
from . import colour, infra from . import colour, infra
from ._version import version # noqa:F401 from ._version import version, version_info # noqa:F401
async def proc() -> typing.Dict[str, typing.Any]: async def proc() -> typing.Dict[str, typing.Any]:
@@ -145,24 +145,13 @@ class AppConfig:
site_name = environ.var("") site_name = environ.var("")
avatar_cache_ttl = environ.var(1800, converter=int) avatar_cache_ttl = environ.var(1800, converter=int)
languages = environ.var([ languages = environ.var([
"da",
"de", "de",
"en", "en",
"fr", "fr",
"id", "id",
"it",
"pl", "pl",
"sv",
], converter=autosplit) ], converter=autosplit)
apple_store_url = environ.var( apple_store_url = environ.var("")
"https://apps.apple.com/us/app/snikket/id1545164189",
)
# Default limit of 1 MiB is what was discovered to be the effective limit
# in #67, hence we set that here for now.
# Future versions may change this default, and the standard deployment
# tools may also very well override it.
max_avatar_size = environ.var(1024*1024, converter=int)
show_metrics = environ.bool_var(True)
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1))) _UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
@@ -175,7 +164,7 @@ def create_app() -> quart.Quart:
pass pass
else: else:
import runpy import runpy
init_vars = runpy.run_path(env_init) init_vars = runpy.run_path(env_init) # type:ignore
for name, value in init_vars.items(): for name, value in init_vars.items():
if not name: if not name:
continue continue
@@ -193,8 +182,6 @@ def create_app() -> quart.Quart:
app.config["SITE_NAME"] = config.site_name or config.domain app.config["SITE_NAME"] = config.site_name or config.domain
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
app.config["APPLE_STORE_URL"] = config.apple_store_url app.config["APPLE_STORE_URL"] = config.apple_store_url
app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size
app.config["SHOW_METRICS"] = config.show_metrics
app.context_processor(proc) app.context_processor(proc)
app.register_error_handler( app.register_error_handler(

View File

@@ -1,15 +1,5 @@
import os version_info = (0, 1, 1, "a0")
import subprocess version = (
".".join(map(str, version_info[:3])) +
version = "(unknown)" (f"-{version_info[3]}" if version_info[3] else "")
)
if os.path.exists(".app_version"):
with open(".app_version") as f:
version = f.read().strip()
elif os.path.exists(".git"):
try:
version = subprocess.check_output([
"git", "describe", "--always"
]).strip().decode("utf8")
except OSError:
version = "dev (unknown)"

View File

@@ -1,6 +1,4 @@
import json import json
import resource
import time
import typing import typing
from datetime import datetime from datetime import datetime
@@ -19,14 +17,13 @@ from quart import (
url_for, url_for,
request, request,
abort, abort,
flash,
current_app,
) )
import flask_wtf
from flask_babel import lazy_gettext as _l, _ from flask_babel import lazy_gettext as _l
from . import prosodyclient, _version from . import prosodyclient
from .infra import client, circle_name, BaseForm from .infra import client, circle_name
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -34,14 +31,11 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/") @bp.route("/")
@client.require_admin_session() @client.require_admin_session()
async def index() -> str: async def index() -> str:
show_metrics = current_app.config["SHOW_METRICS"] return await render_template("admin_home.html")
return await render_template(
"admin_home.html",
show_metrics=show_metrics,
)
class PasswordResetLinkPost(BaseForm): class PasswordResetLinkPost(flask_wtf.FlaskForm): # type: ignore
action_create = wtforms.StringField()
action_revoke = wtforms.StringField() action_revoke = wtforms.StringField()
@@ -52,94 +46,15 @@ async def users() -> str:
await client.list_users(), await client.list_users(),
key=lambda x: x.localpart key=lambda x: x.localpart
) )
invite_form = InvitePost()
await invite_form.init_choices()
reset_form = PasswordResetLinkPost() reset_form = PasswordResetLinkPost()
return await render_template( return await render_template(
"admin_users.html", "admin_users.html",
users=users, users=users,
reset_form=reset_form, reset_form=reset_form,
invite_form=invite_form,
) )
class EditUserForm(BaseForm): class DeleteUserForm(flask_wtf.FlaskForm): # type:ignore
localpart = wtforms.StringField(
_l("Login name"),
)
display_name = wtforms.StringField(
_l("Display name"),
)
role = wtforms.RadioField(
_l("Access Level"),
choices=[
("prosody:restricted", _("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/<localpart>/", 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( action_delete = wtforms.SubmitField(
_l("Delete user permanently") _l("Delete user permanently")
) )
@@ -153,10 +68,6 @@ async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
if form.validate_on_submit(): if form.validate_on_submit():
if form.action_delete.data: if form.action_delete.data:
await client.delete_user_by_localpart(localpart) await client.delete_user_by_localpart(localpart)
await flash(
_("User deleted"),
"success",
)
return redirect(url_for(".users")) return redirect(url_for(".users"))
return await render_template( return await render_template(
@@ -182,47 +93,37 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]:
) )
@bp.route("/users/password-reset/<id_>", methods=["GET", "POST"]) @bp.route("/users/password-reset/-", methods=["POST"])
@client.require_admin_session() @client.require_admin_session()
async def user_password_reset_link( async def create_password_reset_link() -> typing.Union[str, quart.Response]:
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 not found"),
"alert",
)
return redirect(url_for(".users"))
localpart = prosodyclient.split_jid(invite_info.jid)[0]
form = PasswordResetLinkPost() form = PasswordResetLinkPost()
if form.validate_on_submit(): if not 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) 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( return await render_template(
"admin_reset_user_password.html", "admin_reset_user_password.html",
localpart=localpart, target_user=target_user_info,
reset_link=invite_info, reset_link=reset_link,
form=form, form=form,
) )
class InvitesListForm(BaseForm): class InvitesListForm(flask_wtf.FlaskForm): # type:ignore
action_revoke = wtforms.StringField() action_revoke = wtforms.StringField()
class InvitePost(BaseForm): class InvitePost(flask_wtf.FlaskForm): # type:ignore
circles = wtforms.SelectMultipleField( circles = wtforms.SelectMultipleField(
_l("Invite to circle"), _l("Invite to circle"),
# NOTE: This is for when/if we ever support multi-group invites. # NOTE: This is for when/if we ever support multi-group invites.
@@ -316,7 +217,7 @@ async def invitations() -> typing.Union[str, quart.Response]:
) )
class InviteForm(BaseForm): class InviteForm(flask_wtf.FlaskForm): # type:ignore
action_revoke = wtforms.SubmitField( action_revoke = wtforms.SubmitField(
_l("Revoke") _l("Revoke")
) )
@@ -341,10 +242,6 @@ async def create_invite() -> typing.Union[str, quart.Response]:
group_ids=form.circles.data, group_ids=form.circles.data,
ttl=form.lifetime.data, ttl=form.lifetime.data,
) )
await flash(
_("Invitation created"),
"success",
)
return redirect(url_for(".edit_invite", id_=invite.id_)) return redirect(url_for(".edit_invite", id_=invite.id_))
return await render_template("admin_create_invite.html", return await render_template("admin_create_invite.html",
invite_form=form) invite_form=form)
@@ -357,11 +254,7 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
invite_info = await client.get_invite_by_id(id_) invite_info = await client.get_invite_by_id(id_)
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
if exc.status == 404: if exc.status == 404:
await flash( abort(404)
_("No such invitation exists"),
"alert",
)
return redirect(url_for(".invitations"))
circles = await client.list_groups() circles = await client.list_groups()
circle_map = { circle_map = {
circle.id_: circle circle.id_: circle
@@ -372,10 +265,6 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
if form.validate_on_submit(): if form.validate_on_submit():
if form.action_revoke.data: if form.action_revoke.data:
await client.delete_invite(id_) await client.delete_invite(id_)
await flash(
_("Invitation revoked"),
"success",
)
return redirect(url_for(".invitations")) return redirect(url_for(".invitations"))
return redirect(url_for(".edit_invite", id_=id_)) return redirect(url_for(".edit_invite", id_=id_))
@@ -388,7 +277,7 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
) )
class CirclePost(BaseForm): class CirclePost(flask_wtf.FlaskForm): # type:ignore
name = wtforms.StringField( name = wtforms.StringField(
_l("Name"), _l("Name"),
validators=[wtforms.validators.InputRequired()], validators=[wtforms.validators.InputRequired()],
@@ -424,10 +313,6 @@ async def create_circle() -> typing.Union[str, quart.Response]:
circle = await client.create_group( circle = await client.create_group(
name=create_form.name.data, name=create_form.name.data,
) )
await flash(
_("Circle created"),
"success",
)
return redirect(url_for(".edit_circle", id_=circle.id_)) return redirect(url_for(".edit_circle", id_=circle.id_))
return await render_template( return await render_template(
@@ -436,7 +321,7 @@ async def create_circle() -> typing.Union[str, quart.Response]:
) )
class EditCircleForm(BaseForm): class EditCircleForm(flask_wtf.FlaskForm): # type:ignore
name = wtforms.StringField( name = wtforms.StringField(
_l("Name"), _l("Name"),
validators=[wtforms.validators.InputRequired()], validators=[wtforms.validators.InputRequired()],
@@ -473,28 +358,24 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
) )
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
if exc.status == 404: if exc.status == 404:
await flash(
_("No such circle exists"),
"alert",
)
return redirect(url_for(".circles")) return redirect(url_for(".circles"))
raise raise
users = { users = sorted(
user.localpart: user await client.list_users(),
for user in await client.list_users() key=lambda x: x.localpart
} )
circle_members = [ circle_members = [
(localpart, users.get(localpart)) user for user in users
for localpart in sorted(circle.members) if user.localpart in circle.members
] ]
form = EditCircleForm() form = EditCircleForm()
form.user_to_add.choices = sorted( form.user_to_add.choices = [
(localpart, localpart) (user.localpart, user.localpart)
for localpart in users.keys() for user in users
if localpart not in circle.members if user.localpart not in circle.members
) ]
valid_users = [x[0] for x in form.user_to_add.choices] valid_users = [x[0] for x in form.user_to_add.choices]
invite_form = InvitePost() invite_form = InvitePost()
@@ -510,38 +391,25 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
id_, id_,
new_name=form.name.data, new_name=form.name.data,
) )
await flash(
_("Circle data updated"),
"success",
)
elif form.action_delete.data: elif form.action_delete.data:
await client.delete_group(id_) await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles")) return redirect(url_for(".circles"))
elif form.action_add_user.data: elif form.action_add_user.data:
if form.user_to_add.data in valid_users: if form.user_to_add.data in valid_users:
print("is valid")
await client.add_group_member( await client.add_group_member(
id_, id_,
form.user_to_add.data, form.user_to_add.data,
) )
await flash(
_("User added to circle"),
"success",
)
elif form.action_remove_user.data: elif form.action_remove_user.data:
await client.remove_group_member( await client.remove_group_member(
id_, id_,
form.action_remove_user.data, form.action_remove_user.data,
) )
await flash(
_("User removed from circle"),
"success",
)
return redirect(url_for(".edit_circle", id_=id_)) return redirect(url_for(".edit_circle", id_=id_))
else:
print(form.errors)
return await render_template( return await render_template(
"admin_edit_circle.html", "admin_edit_circle.html",
@@ -550,148 +418,3 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
circle_members=circle_members, circle_members=circle_members,
invite_form=invite_form, invite_form=invite_form,
) )
_CPU_EPOCH = time.process_time()
_MONOTONIC_EPOCH = time.monotonic()
def get_system_stats() -> typing.MutableMapping[
str,
typing.Optional[typing.Union[int, float]]]:
pagesize = resource.getpagesize()
my_rss: typing.Optional[int] = None
try:
with open("/proc/self/statm") as f:
stats = f.read().split()
my_rss = int(stats[1]) * pagesize
except (ValueError, IndexError, TypeError, OSError):
pass
my_cpu = (
(time.process_time() - _CPU_EPOCH) /
(time.monotonic() - _MONOTONIC_EPOCH)
)
mem_total, mem_available = None, None
load5: typing.Optional[float] = None
try:
with open("/proc/loadavg") as f:
stats = f.read().split()
load5 = float(stats[1])
except (ValueError, IndexError, TypeError, OSError):
pass
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal"):
mem_total = int(line.split()[1]) * 1024
elif line.startswith("MemAvailable"):
mem_available = int(line.split()[1]) * 1024
if mem_total is not None and mem_available is not None:
break
except (ValueError, TypeError, IndexError, OSError):
pass
return {
"portal_rss": my_rss,
"portal_cpu": my_cpu,
"load5": load5,
"mem_total": mem_total,
"mem_available": mem_available,
}
class AnnouncementForm(BaseForm):
text = wtforms.StringField(
_("Message contents"),
widget=wtforms.widgets.TextArea(),
validators=[wtforms.validators.DataRequired()],
)
online_only = wtforms.BooleanField(
_("Only send to online users"),
)
action_post_all = wtforms.SubmitField(
_("Post to all users"),
)
action_send_preview = wtforms.SubmitField(
_("Send preview to yourself"),
)
@bp.route("/system/", methods=["GET", "POST"])
@client.require_admin_session()
async def system() -> typing.Union[str, quart.Response]:
form = AnnouncementForm()
if form.validate_on_submit():
recipients = "self"
if form.action_post_all.data:
if form.online_only.data:
recipients = "online"
else:
recipients = "all"
await client.post_announcement(
form.text.data,
recipients=recipients,
)
await flash(
_("Announcement sent!"),
"success",
)
if recipients != "self":
# redirect only if not previewing
return redirect(url_for(".system"))
version = None
now = None
show_metrics = current_app.config["SHOW_METRICS"]
if show_metrics:
version = await client.get_server_version()
now = time.time()
try:
prosody_metrics = await client.get_system_metrics()
except quart.exceptions.NotFound:
# server does not offer the endpoint for whatever reason -- ignore
prosody_metrics = {}
metrics = get_system_stats()
try:
prosody_cpu_metrics = prosody_metrics["cpu"]
except KeyError:
pass
else:
metrics["prosody_cpu"] = (prosody_cpu_metrics["value"] /
(now - prosody_cpu_metrics["since"]))
try:
metrics["prosody_rss"] = prosody_metrics["memory"]
except KeyError:
pass
try:
metrics["prosody_devices"] = prosody_metrics["c2s"]
except KeyError:
pass
for k in list(metrics.keys()):
if metrics[k] is None:
# so that defaulting in jinja works
del metrics[k]
else:
metrics = {}
return await render_template(
"admin_system.html",
metrics=metrics,
version=_version.version,
prosody_version=version,
form=form,
show_metrics=show_metrics,
)

View File

@@ -1,6 +1,5 @@
import base64 import base64
import itertools import itertools
import math
import secrets import secrets
import typing import typing
@@ -11,7 +10,6 @@ from quart import (
) )
import flask_babel import flask_babel
import flask_wtf
from flask_babel import _ from flask_babel import _
from . import prosodyclient from . import prosodyclient
@@ -23,20 +21,11 @@ client.default_login_redirect = "main.login"
babel = flask_babel.Babel() babel = flask_babel.Babel()
BYTE_UNIT_SCALE_MAP = [
"B",
"kiB",
"MiB",
"GiB",
"TiB",
]
@babel.localeselector # type:ignore @babel.localeselector # type:ignore
def selected_locale() -> str: def selected_locale() -> str:
selected = request.accept_languages.best_match( selected = request.accept_languages.best_match(
current_app.config['LANGUAGES'] current_app.config['LANGUAGES']
) or current_app.config['LANGUAGES'][0] )
return selected return selected
@@ -52,27 +41,12 @@ def circle_name(c: typing.Any) -> str:
return c.name return c.name
def format_bytes(n: float) -> str:
scale = math.floor(math.log(n, 1024))
try:
unit = BYTE_UNIT_SCALE_MAP[scale]
factor = 1024**scale
except ValueError:
unit = "TiB"
factor = 1024**4
if factor > 1:
return "{:.1f}{}".format(n / factor, unit)
return "{}{}".format(n, unit)
def init_templating(app: quart.Quart) -> None: def init_templating(app: quart.Quart) -> None:
app.template_filter("repr")(repr) app.template_filter("repr")(repr)
app.template_filter("format_datetime")(flask_babel.format_datetime) app.template_filter("format_datetime")(flask_babel.format_datetime)
app.template_filter("format_date")(flask_babel.format_date) app.template_filter("format_date")(flask_babel.format_date)
app.template_filter("format_time")(flask_babel.format_time) app.template_filter("format_time")(flask_babel.format_time)
app.template_filter("format_timedelta")(flask_babel.format_timedelta) app.template_filter("format_timedelta")(flask_babel.format_timedelta)
app.template_filter("format_percent")(flask_babel.format_percent)
app.template_filter("format_bytes")(format_bytes)
app.template_filter("flatten")(flatten) app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name) app.template_filter("circle_name")(circle_name)
@@ -81,14 +55,3 @@ def generate_error_id() -> str:
return base64.b32encode(secrets.token_bytes(8)).decode( return base64.b32encode(secrets.token_bytes(8)).decode(
"ascii" "ascii"
).rstrip("=") ).rstrip("=")
class BaseForm(flask_wtf.FlaskForm): # type:ignore
def __init__(self, *args: typing.Any, **kwargs: typing.Any):
meta = kwargs["meta"] = dict(kwargs.get("meta", {}))
if "locales" not in meta:
locale = flask_babel.get_locale()
if locale:
meta["locales"] = [str(locale)]
super().__init__(*args, **kwargs)

View File

@@ -16,9 +16,10 @@ from quart import (
import wtforms import wtforms
import flask_wtf
from flask_babel import lazy_gettext as _l from flask_babel import lazy_gettext as _l
from .infra import client, selected_locale, BaseForm from .infra import client, selected_locale
bp = Blueprint("invite", __name__) bp = Blueprint("invite", __name__)
@@ -52,15 +53,13 @@ async def view_old(id_: str) -> quart.Response:
@bp.route("/<id_>/") @bp.route("/<id_>/")
async def view(id_: str) -> typing.Union[quart.Response, async def view(id_: str) -> str:
typing.Tuple[str, int],
str]:
try: try:
invite = await client.get_public_invite_by_id(id_) invite = await client.get_public_invite_by_id(id_)
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
if exc.status == 404: if exc.status == 404:
# invite expired # invite expired
return await render_template("invite_invalid.html"), 404 return await render_template("invite_invalid.html")
raise raise
if invite.reset_localpart is not None: if invite.reset_localpart is not None:
@@ -85,23 +84,16 @@ async def view(id_: str) -> typing.Union[quart.Response,
) )
apple_store_url = current_app.config["APPLE_STORE_URL"] apple_store_url = current_app.config["APPLE_STORE_URL"]
body = await render_template( return await render_template(
"invite_view.html", "invite_view.html",
invite=invite, invite=invite,
play_store_url=play_store_url, play_store_url=play_store_url,
apple_store_url=apple_store_url, apple_store_url=apple_store_url,
f_droid_url="market://details?id=org.snikket.android",
invite_id=id_, invite_id=id_,
) )
return quart.Response(
body,
headers={
"Link": "<{}> rel=\"alternate\"".format(invite.xmpp_uri),
}
)
class RegisterForm(BaseForm): class RegisterForm(flask_wtf.FlaskForm): # type:ignore
localpart = wtforms.StringField( localpart = wtforms.StringField(
_l("Username"), _l("Username"),
) )
@@ -115,7 +107,7 @@ class RegisterForm(BaseForm):
validators=[wtforms.validators.InputRequired(), validators=[wtforms.validators.InputRequired(),
wtforms.validators.EqualTo( wtforms.validators.EqualTo(
"password", "password",
_l("The passwords must match.") _l("The passwords must match")
)] )]
) )
@@ -147,15 +139,15 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
if exc.status == 409: if exc.status == 409:
form.localpart.errors.append( form.localpart.errors.append(
_l("That username is already taken.") _l("That username is already taken")
) )
elif exc.status == 403: elif exc.status == 403:
form.localpart.errors.append( form.localpart.errors.append(
_l("Registration was declined for unknown reasons.") _l("Registration was declined for unknown reasons")
) )
elif exc.status == 400: elif exc.status == 400:
form.localpart.errors.append( form.localpart.errors.append(
_l("The username is not valid.") _l("The username is not valid")
) )
elif exc.status == 404: elif exc.status == 404:
return redirect(url_for(".view", id_=id_)) return redirect(url_for(".view", id_=id_))
@@ -172,7 +164,7 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
) )
class ResetForm(BaseForm): class ResetForm(flask_wtf.FlaskForm): # type:ignore
password = wtforms.PasswordField( password = wtforms.PasswordField(
_l("Password"), _l("Password"),
) )
@@ -182,7 +174,7 @@ class ResetForm(BaseForm):
validators=[wtforms.validators.InputRequired(), validators=[wtforms.validators.InputRequired(),
wtforms.validators.EqualTo( wtforms.validators.EqualTo(
"password", "password",
_l("The passwords must match.") _l("The passwords must match")
)] )]
) )
@@ -215,7 +207,7 @@ async def reset(id_: str) -> typing.Union[str, quart.Response]:
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
if exc.status == 403: if exc.status == 403:
form.localpart.errors.append( form.localpart.errors.append(
_l("Registration was declined for unknown reasons.") _l("Registration was declined for unknown reasons")
) )
elif exc.status == 404: elif exc.status == 404:
return redirect(url_for(".view", id_=id_)) return redirect(url_for(".view", id_=id_))

View File

@@ -15,23 +15,23 @@ from quart import (
render_template, render_template,
request, request,
Response, Response,
flash,
) )
import babel import babel
import wtforms import wtforms
import flask_wtf import flask_wtf
from flask_babel import lazy_gettext as _l, _ from flask_babel import lazy_gettext as _l, _
from . import xmpputil, _version from . import xmpputil, _version
from .infra import client, BaseForm from .infra import client
bp = quart.Blueprint("main", __name__) bp = quart.Blueprint("main", __name__)
class LoginForm(BaseForm): class LoginForm(flask_wtf.FlaskForm): # type:ignore
address = wtforms.TextField( address = wtforms.TextField(
_l("Address"), _l("Address"),
validators=[wtforms.validators.InputRequired()], validators=[wtforms.validators.InputRequired()],
@@ -52,9 +52,6 @@ async def index() -> quart.Response:
return redirect(url_for("index")) return redirect(url_for("index"))
ERR_CREDENTIALS_INVALID = _l("Invalid username or password.")
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
async def login() -> typing.Union[str, quart.Response]: async def login() -> typing.Union[str, quart.Response]:
if client.has_session and (await client.test_session()): if client.has_session and (await client.test_session()):
@@ -66,24 +63,16 @@ async def login() -> typing.Union[str, quart.Response]:
localpart, domain, resource = xmpputil.split_jid(jid) localpart, domain, resource = xmpputil.split_jid(jid)
if not localpart: if not localpart:
localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"] localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"]
if domain != current_app.config["SNIKKET_DOMAIN"]: jid = "{}@{}".format(localpart, domain)
# (a) prosody throws a 400 at us and I prefer to catch that here password = form.password.data
# and (b) I dont want to pass on this obviously not-for-here try:
# password further than necessary. await client.login(jid, password)
form.password.errors.append(ERR_CREDENTIALS_INVALID) except quart.exceptions.Unauthorized:
form.password.errors.append(
_("Invalid username or password.")
)
else: else:
jid = "{}@{}".format(localpart, domain) return redirect(url_for('user.index'))
password = form.password.data
try:
await client.login(jid, password)
except quart.exceptions.Unauthorized:
form.password.errors.append(ERR_CREDENTIALS_INVALID)
else:
await flash(
_("Login successful!"),
"success"
)
return redirect(url_for('user.index'))
return await render_template("login.html", form=form) return await render_template("login.html", form=form)
@@ -100,10 +89,6 @@ async def about() -> str:
extra_versions["babel"] = babel.__version__ extra_versions["babel"] = babel.__version__
extra_versions["wtforms"] = wtforms.__version__ extra_versions["wtforms"] = wtforms.__version__
extra_versions["flask-wtf"] = flask_wtf.__version__ extra_versions["flask-wtf"] = flask_wtf.__version__
try:
extra_versions["Prosody"] = await client.get_server_version()
except quart.exceptions.Unauthorized:
extra_versions["Prosody"] = "unknown"
return await render_template( return await render_template(
"about.html", "about.html",
@@ -123,7 +108,6 @@ def repad(s: str) -> str:
@bp.route("/avatar/<from_>/<code>") @bp.route("/avatar/<from_>/<code>")
async def avatar(from_: str, code: str) -> quart.Response: async def avatar(from_: str, code: str) -> quart.Response:
etag: typing.Optional[str]
try: try:
etag = request.headers["if-none-match"] etag = request.headers["if-none-match"]
except KeyError: except KeyError:
@@ -163,8 +147,3 @@ async def avatar(from_: str, code: str) -> quart.Response:
response.set_data(data) response.set_data(data)
return response return response
@bp.route("/_health")
async def health() -> Response:
return Response("STATUS OK", content_type="text/plain")

View File

@@ -44,15 +44,6 @@ class AdminUserInfo:
display_name: typing.Optional[str] display_name: typing.Optional[str]
email: typing.Optional[str] email: typing.Optional[str]
phone: 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 @classmethod
def from_api_response( def from_api_response(
@@ -64,7 +55,6 @@ class AdminUserInfo:
display_name=data.get("display_name") or None, display_name=data.get("display_name") or None,
email=data.get("email") or None, email=data.get("email") or None,
phone=data.get("phone") or None, phone=data.get("phone") or None,
roles=data.get("roles"),
) )
@@ -342,18 +332,15 @@ class ProsodyClient:
) )
) )
def _store_token_in_session(self, token_info: TokenInfo) -> None:
http_session[self.SESSION_TOKEN] = token_info.token
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
async def login(self, jid: str, password: str) -> bool: async def login(self, jid: str, password: str) -> bool:
async with self._plain_session as session: async with self._plain_session as session:
token_info = await self._oauth2_bearer_token( token_info = await self._oauth2_bearer_token(
session, jid, password, session, jid, password,
) )
self._store_token_in_session(token_info) http_session[self.SESSION_TOKEN] = token_info.token
http_session[self.SESSION_ADDRESS] = jid http_session[self.SESSION_ADDRESS] = jid
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
return True return True
@property @property
@@ -458,13 +445,6 @@ class ProsodyClient:
headers=final_headers, headers=final_headers,
data=serialised) as resp: data=serialised) as resp:
if resp.status != 200: if resp.status != 200:
self.logger.debug(
"IQ HTTP response (in-reply-to id=%s) with non-OK status "
"%s: %s",
id_,
resp.status,
resp.reason,
)
abort(resp.status) abort(resp.status)
reply_payload = await resp.read() reply_payload = await resp.read()
self.logger.debug( self.logger.debug(
@@ -510,32 +490,9 @@ class ProsodyClient:
"to": self.session_address, "to": self.session_address,
} }
async with session.post(self._rest_endpoint, json=req) as resp: async with session.post(self._rest_endpoint, data=req) as resp:
return resp.status == 200 return resp.status == 200
@autosession
async def get_server_version(self, session: aiohttp.ClientSession) -> str:
_, domain, _ = split_jid(self.session_address)
req = {
"kind": "iq",
"type": "get",
"version": {},
"to": domain,
}
async with session.post(self._rest_endpoint, json=req) as resp:
if resp.status != 200:
return "unknwn"
try:
return (await resp.json())["version"]["version"]
except Exception as exc:
self.logger.debug(
"failed to parse prosody version from response"
" (%s: %s)",
type(exc), exc,
)
return "unknown"
@autosession @autosession
async def get_user_nickname( async def get_user_nickname(
self, self,
@@ -810,7 +767,7 @@ class ProsodyClient:
# got there, replacing the current session token on the way. # got there, replacing the current session token on the way.
async with self._plain_session as session: async with self._plain_session as session:
token_info = await self._oauth2_bearer_token( token = await self._oauth2_bearer_token(
session, session,
self.session_address, self.session_address,
current_password, current_password,
@@ -822,14 +779,14 @@ class ProsodyClient:
new_password new_password
), ),
headers={ headers={
"Authorization": "Bearer {}".format(token_info.token), "Authorization": "Bearer {}".format(token),
}, },
sensitive=True, sensitive=True,
) )
# TODO: error handling # TODO: error handling
# TODO: obtain a new token using the new password to allow the # TODO: obtain a new token using the new password to allow the
# server to expire/revoke all tokens on password change. # server to expire/revoke all tokens on password change.
self._store_token_in_session(token_info) http_session[self.SESSION_TOKEN] = token
def _raise_error_from_response( def _raise_error_from_response(
self, self,
@@ -868,29 +825,6 @@ class ProsodyClient:
self._raise_error_from_response(resp) self._raise_error_from_response(resp)
return AdminUserInfo.from_api_response(await resp.json()) 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 @autosession
async def get_user_debug_info( async def get_user_debug_info(
self, self,
@@ -1175,41 +1109,3 @@ class ProsodyClient:
json=payload) as resp: json=payload) as resp:
resp.raise_for_status() resp.raise_for_status()
return (await resp.json())["jid"] return (await resp.json())["jid"]
@autosession
async def get_system_metrics(
self,
*,
session: aiohttp.ClientSession) -> typing.Mapping:
async with session.get(
self._admin_v1_endpoint("/server/metrics"),
) as resp:
if resp.status == 404:
return {}
self._raise_error_from_response(resp)
resp.raise_for_status()
return await resp.json()
@autosession
async def post_announcement(
self,
body: str,
recipients: str,
*,
session: aiohttp.ClientSession) -> None:
recipients_payload: typing.Union[str, typing.Sequence[str]]
if recipients == "self":
recipients_payload = [self.session_address]
else:
recipients_payload = recipients
payload = {
"recipients": recipients_payload,
"body": body,
}
async with session.post(
self._admin_v1_endpoint("/server/announcement"),
json=payload) as resp:
self._raise_error_from_response(resp)
resp.raise_for_status()

View File

@@ -252,4 +252,3 @@ $h-sizes: [200.0%, 174.11011266%, 151.57165665%, 131.95079108%, 114.8698355%, 10
$h-small-sizes: [150.0%, 138.31618672%, 127.54245006%, 117.60790225%, 108.44717712%, 100.0%]; $h-small-sizes: [150.0%, 138.31618672%, 127.54245006%, 117.60790225%, 108.44717712%, 100.0%];
$small-screen-threshold: 40rem; $small-screen-threshold: 40rem;
$medium-screen-threshold: 60rem; $medium-screen-threshold: 60rem;
$large-screen-threshold: 80rem;

View File

@@ -33,35 +33,13 @@ body {
main { main {
padding: $w-l1; padding: $w-l1;
margin-left: auto;
max-width: 60rem;
margin-right: auto;
} }
#mwrap { #mwrap {
flex: 1; flex: 1;
display: flex;
flex-direction: row-reverse;
> .filler, > .flashbox {
flex: 1 1 1rem;
}
> main {
flex: 0 1 60rem;
}
}
@media screen and (max-width: $large-screen-threshold) {
#mwrap {
display: block;
> main {
margin-left: auto;
margin-right: auto;
}
}
}
.flashbox > div.box > :first-child {
margin-top: 0;
} }
/* top bar */ /* top bar */
@@ -354,15 +332,6 @@ div.form.layout-expanded {
display: block; 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 { div.select-wrap {
display: block; display: block;
border-bottom: $w-s4 solid $primary-500; border-bottom: $w-s4 solid $primary-500;
@@ -1026,23 +995,6 @@ div.profile-card {
display: none; display: none;
} }
} }
input[type="submit"], button, .button {
&.slimmify {
> svg.icon {
margin-right: 0;
}
> span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
top: -100px;
}
}
}
} }
/* clipboard button */ /* clipboard button */

View File

@@ -54,8 +54,6 @@ div.install-buttons {
ul { ul {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap;
justify-content: center;
list-style-type: none; list-style-type: none;
margin: $w-l1 0; margin: $w-l1 0;
padding: 0; padding: 0;
@@ -76,10 +74,6 @@ img.play {
height: $w-l3; height: $w-l3;
} }
img.fdroid {
height: $w-l3;
}
.tabbox { .tabbox {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -37,11 +37,6 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" /> <path d="M0 0h24v24H0V0z" fill="none" />
<path d="M10.79 16.29c.39.39 1.02.39 1.41 0l3.59-3.59c.39-.39.39-1.02 0-1.41L12.2 7.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L12.67 11H4c-.55 0-1 .45-1 1s.45 1 1 1h8.67l-1.88 1.88c-.39.39-.38 1.03 0 1.41zM19 3H5c-1.11 0-2 .9-2 2v3c0 .55.45 1 1 1s1-.45 1-1V6c0-.55.45-1 1-1h12c.55 0 1 .45 1 1v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1v3c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" /> <path d="M10.79 16.29c.39.39 1.02.39 1.41 0l3.59-3.59c.39-.39.39-1.02 0-1.41L12.2 7.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L12.67 11H4c-.55 0-1 .45-1 1s.45 1 1 1h8.67l-1.88 1.88c-.39.39-.38 1.03 0 1.41zM19 3H5c-1.11 0-2 .9-2 2v3c0 .55.45 1 1 1s1-.45 1-1V6c0-.55.45-1 1-1h12c.55 0 1 .45 1 1v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1v3c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</symbol> </symbol>
<!-- from: action/lock/materialiconsround/24px.svg -->
<symbol id="icon-lock" viewBox="0 0 24 24">
<g fill="none"><path d="M0 0h24v24H0V0z" /><path d="M0 0h24v24H0V0z" opacity=".87" /></g>
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z" />
</symbol>
<!-- from: communication/qr_code/materialiconsround/24px.svg --> <!-- from: communication/qr_code/materialiconsround/24px.svg -->
<symbol id="icon-qrcode" viewBox="0 0 24 24"> <symbol id="icon-qrcode" viewBox="0 0 24 24">
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g> <g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
@@ -52,12 +47,6 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" /> <path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" /> <path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
</symbol> </symbol>
<!-- from: communication/rss_feed/materialiconsround/24px.svg -->
<symbol id="icon-broadcast" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<circle cx="6.18" cy="17.82" r="2.18" />
<path d="M5.59 10.23c-.84-.14-1.59.55-1.59 1.4 0 .71.53 1.28 1.23 1.4 2.92.51 5.22 2.82 5.74 5.74.12.7.69 1.23 1.4 1.23.85 0 1.54-.75 1.41-1.59-.68-4.2-3.99-7.51-8.19-8.18zm-.03-5.71C4.73 4.43 4 5.1 4 5.93c0 .73.55 1.33 1.27 1.4 6.01.6 10.79 5.38 11.39 11.39.07.73.67 1.28 1.4 1.28.84 0 1.5-.73 1.42-1.56-.73-7.34-6.57-13.19-13.92-13.92z" />
</symbol>
<!-- from: content/add_circle_outline/materialiconsround/24px.svg --> <!-- from: content/add_circle_outline/materialiconsround/24px.svg -->
<symbol id="icon-add" viewBox="0 0 24 24"> <symbol id="icon-add" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" /> <path d="M0 0h24v24H0V0z" fill="none" />
@@ -83,11 +72,6 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" /> <path d="M0 0h24v24H0V0z" fill="none" />
<path d="M21.94 11.23C21.57 8.76 19.32 7 16.82 7h-2.87c-.52 0-.95.43-.95.95s.43.95.95.95h2.9c1.6 0 3.04 1.14 3.22 2.73.17 1.43-.64 2.69-1.85 3.22l1.4 1.4c1.63-1.02 2.64-2.91 2.32-5.02zM4.12 3.56c-.39-.39-1.02-.39-1.41 0s-.39 1.02 0 1.41l2.4 2.4c-1.94.8-3.27 2.77-3.09 5.04C2.23 15.05 4.59 17 7.23 17h2.82c.52 0 .95-.43.95-.95s-.43-.95-.95-.95H7.16c-1.63 0-3.1-1.19-3.25-2.82-.15-1.72 1.11-3.17 2.75-3.35l2.1 2.1c-.43.09-.76.46-.76.92v.1c0 .52.43.95.95.95h1.78L13 15.27V17h1.73l3.3 3.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L4.12 3.56zM16 11.95c0-.52-.43-.95-.95-.95h-.66l1.49 1.49c.07-.13.12-.28.12-.44v-.1z" /> <path d="M21.94 11.23C21.57 8.76 19.32 7 16.82 7h-2.87c-.52 0-.95.43-.95.95s.43.95.95.95h2.9c1.6 0 3.04 1.14 3.22 2.73.17 1.43-.64 2.69-1.85 3.22l1.4 1.4c1.63-1.02 2.64-2.91 2.32-5.02zM4.12 3.56c-.39-.39-1.02-.39-1.41 0s-.39 1.02 0 1.41l2.4 2.4c-1.94.8-3.27 2.77-3.09 5.04C2.23 15.05 4.59 17 7.23 17h2.82c.52 0 .95-.43.95-.95s-.43-.95-.95-.95H7.16c-1.63 0-3.1-1.19-3.25-2.82-.15-1.72 1.11-3.17 2.75-3.35l2.1 2.1c-.43.09-.76.46-.76.92v.1c0 .52.43.95.95.95h1.78L13 15.27V17h1.73l3.3 3.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L4.12 3.56zM16 11.95c0-.52-.43-.95-.95-.95h-.66l1.49 1.49c.07-.13.12-.28.12-.44v-.1z" />
</symbol> </symbol>
<!-- from: content/send/materialiconsround/24px.svg -->
<symbol id="icon-send" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3.4 20.4l17.45-7.48c.81-.35.81-1.49 0-1.84L3.4 3.6c-.66-.29-1.39.2-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
</symbol>
<!-- from: navigation/arrow_back/materialiconsround/24px.svg --> <!-- from: navigation/arrow_back/materialiconsround/24px.svg -->
<symbol id="icon-back" viewBox="0 0 24 24"> <symbol id="icon-back" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" /> <path d="M0 0h24v24H0V0z" fill="none" />
@@ -153,9 +137,4 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" /> <path d="M0 0h24v24H0V0z" fill="none" />
<path d="M17 7h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c1.65 0 3 1.35 3 3s-1.35 3-3 3h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-9 5c0 .55.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1H9c-.55 0-1 .45-1 1zm2 3H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h3c.55 0 1-.45 1-1s-.45-1-1-1H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h3c.55 0 1-.45 1-1s-.45-1-1-1z" /> <path d="M17 7h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c1.65 0 3 1.35 3 3s-1.35 3-3 3h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-9 5c0 .55.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1H9c-.55 0-1 .45-1 1zm2 3H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h3c.55 0 1-.45 1-1s-.45-1-1-1H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h3c.55 0 1-.45 1-1s-.45-1-1-1z" />
</symbol> </symbol>
<!-- from: content/insights/materialiconsround/24px.svg -->
<symbol id="icon-insights" viewBox="0 0 24 24">
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
<g><g><path d="M21,8c-1.45,0-2.26,1.44-1.93,2.51l-3.55,3.56c-0.3-0.09-0.74-0.09-1.04,0l-2.55-2.55C12.27,10.45,11.46,9,10,9 c-1.45,0-2.27,1.44-1.93,2.52l-4.56,4.55C2.44,15.74,1,16.55,1,18c0,1.1,0.9,2,2,2c1.45,0,2.26-1.44,1.93-2.51l4.55-4.56 c0.3,0.09,0.74,0.09,1.04,0l2.55,2.55C12.73,16.55,13.54,18,15,18c1.45,0,2.27-1.44,1.93-2.52l3.56-3.55 C21.56,12.26,23,11.45,23,10C23,8.9,22.1,8,21,8z" /><polygon points="15,9 15.94,6.93 18,6 15.94,5.07 15,3 14.08,5.07 12,6 14.08,6.93" /><polygon points="3.5,11 4,9 6,8.5 4,8 3.5,6 3,8 1,8.5 3,9" /></g></g>
</symbol>
</defs></svg> </defs></svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "library.j2" import standard_button %} {% from "library.j2" import standard_button %}
{% block head_lead %} {% block head_lead %}
<title>{% trans %}About Snikket{% endtrans %}</title> <title>About Snikket</title>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<main> <main>

View File

@@ -16,7 +16,7 @@
<p>{% trans %}The user and their data will be deleted irrevocably, permanently and immediately upon pushing the below button. <strong>There is no way back!</strong>{% endtrans %}</p> <p>{% trans %}The user and their data will be deleted irrevocably, permanently and immediately upon pushing the below button. <strong>There is no way back!</strong>{% endtrans %}</p>
{% endcall %} {% endcall %}
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for(".edit_user", localpart=target_user.localpart), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} {%- call standard_button("back", url_for(".index"), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%} {%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
</div> </div>
</form></div> </form></div>

View File

@@ -1,5 +1,5 @@
{% extends "admin_app.html" %} {% extends "admin_app.html" %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon %} {% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button %}
{% block head_lead %} {% block head_lead %}
{{ super() }} {{ super() }}
{% include "copy-snippet.html" %} {% include "copy-snippet.html" %}
@@ -40,8 +40,8 @@
{%- endif -%} {%- endif -%}
</div> </div>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%} {%- call standard_button("back", url_for(".circles"), class="secondary") -%}
{% trans %}Return to circle list{% endtrans %} {% trans %}Back{% endtrans %}
{%- endcall -%} {%- endcall -%}
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
</div> </div>
@@ -56,21 +56,14 @@
{%- if circle_members -%} {%- if circle_members -%}
<div class="el-2 elevated"><table> <div class="el-2 elevated"><table>
<thead> <thead>
<th>{% trans %}Login name{% endtrans %}</th> <th>Login name</th>
<th class="collapsible">{% trans %}Display name{% endtrans %}</th> <th class="collapsible">Display name</th>
<th>{% trans %}Actions{% endtrans %}</th> <th>Actions</th>
</thead> </thead>
<tbody> <tbody>
{%- for localpart, member in circle_members -%} {%- for member in circle_members -%}
<tr> <tr>
<td> <td>{{ member.localpart }}</td>
{%- if member -%}
{{ localpart }}
{%- else -%}
{{ localpart }}
<span class="with-tooltip above" data-tooltip="{% trans %}The user has been deleted from the server.{% endtrans %}"><em> ({% trans %}deleted{% endtrans %})</em></span>
{%- endif -%}
</td>
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td> <td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
<td class="nowrap"> <td class="nowrap">
{%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%} {%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}

View File

@@ -44,10 +44,10 @@
<dd>{{ invite.created_at | format_date }}</dd> <dd>{{ invite.created_at | format_date }}</dd>
</dl> </dl>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for(".invitations"), class="tertiary") %} {%- call form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%}
{% trans %}Return to invitation list{% endtrans %} {%- call standard_button("back", url_for(".invitations"), class="primary") %}
{% trans %}Back{% endtrans %}
{%- endcall %} {%- endcall %}
{%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%}
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,78 +0,0 @@
{% 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 %}
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
<h2 class="form-title">{% trans %}Edit user{% endtrans %}</h2>
<div class="f-ebox">
{{ form.localpart.label }}
{{ form.localpart(readonly="readonly") }}
<p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p>
</div>
<div class="f-ebox">
{{ form.display_name.label }}
{{ form.display_name }}
</div>
<h3 class="form-title">{% trans %}Access Level{% endtrans %}</h3>
<p class="form-descr weak">{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}</p>
<div class="f-ebox">
<fieldset>{#- -#}
<legend class="a11y-only">{{ form.role.label.text }}</legend>
{%- for level in form.role -%}
<div class="radio-button-ext">
{{ level }}<label for="{{ level.id }}">
{%- trans title=level.label.text, icon=access_level_icon(level.data), description=access_level_description(level.data) -%}
<strong>{{ title }}{{ icon }}</strong><p>{{ description }}</p>
{%- endtrans -%}
</label>
</div>
{%- endfor -%}
</fieldset>
</div>
<div class="f-bbox">
{%- 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 -%}
</div>
</div>
<h2>{% trans %}Further actions{% endtrans %}</h2>
<div class="form layout-expanded">
<h2 class="form-title">{% trans %}Reset password{% endtrans %}</h2>
{{ form.csrf_token }}
<p class="form-desc">
{% 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 %}
</p>
<div class="f-bbox">
{%- call form_button("passwd", form.action_create_reset, class="primary") -%}{%- endcall -%}
</div>
<h2 class="form-title">{% trans %}Debug information{% endtrans %}</h2>
<p class="form-desc">
{% 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 %}
</p>
<div class="f-bbox">
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="primary") -%}
{%- trans -%}Show debug information{%- endtrans -%}
{%- endcall -%}
</div>
</div></form>
{% endblock %}

View File

@@ -31,18 +31,6 @@
<div>{% call standard_button("link", url_for(".invitations"), class="primary") %}{% trans %}Manage invitations{% endtrans %}{% endcall %}</div> <div>{% call standard_button("link", url_for(".invitations"), class="primary") %}{% trans %}Manage invitations{% endtrans %}{% endcall %}</div>
{#- -#} {#- -#}
</li> </li>
<li>
<h2>{% trans %}System health{% endtrans %}</h2>
{#- -#}
{%- if show_metrics -%}
<p>{% trans %}View the server status or send a broadcast message to all users.{% endtrans %}</p>
{%- else -%}
<p>{% trans %}Send a broadcast message to all users.{% endtrans %}</p>
{%- endif -%}
{#- -#}
<div>{% call standard_button("insights", url_for(".system"), class="primary") %}{% trans %}Manage system{% endtrans %}{% endcall %}</div>
{#- -#}
</li>
<li> <li>
{#- -#} {#- -#}
<p>{% trans %}Go back to your user's web portal page.{% endtrans %}</p> <p>{% trans %}Go back to your user's web portal page.{% endtrans %}</p>

View File

@@ -18,7 +18,7 @@
<col/> <col/>
<thead> <thead>
<tr> <tr>
<th>{% trans %}Expires{% endtrans %}</th> <th>{% trans %}Valid until{% endtrans %}</th>
<th class="collapsible">{% trans %}Type{% endtrans %}</th> <th class="collapsible">{% trans %}Type{% endtrans %}</th>
<th class="collapsible">{% trans %}Circle{% endtrans %}</th> <th class="collapsible">{% trans %}Circle{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>

View File

@@ -9,7 +9,7 @@
<form method="POST"> <form method="POST">
{{- form.csrf_token -}} {{- form.csrf_token -}}
<div class="form layout-expanded"> <div class="form layout-expanded">
<h2 class="form-title">{% trans user_name=localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2> <h2 class="form-title">{% trans user_name=target_user.localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2>
<p class="form-desc">{% trans %}The following link will allow the user to reset their password on their device, once.{% endtrans %}</p> <p class="form-desc">{% trans %}The following link will allow the user to reset their password on their device, once.{% endtrans %}</p>
<dd> <dd>
<dt>{% trans %}Valid until{% endtrans %}</dt> <dt>{% trans %}Valid until{% endtrans %}</dt>
@@ -21,7 +21,7 @@
{%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%} {%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%}
{% trans %}Destroy link{% endtrans %} {% trans %}Destroy link{% endtrans %}
{%- endcall -%} {%- endcall -%}
{%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%} {%- call standard_button("back", url_for(".users"), class="primary") -%}
{% trans %}Back{% endtrans %} {% trans %}Back{% endtrans %}
{%- endcall -%} {%- endcall -%}
</div> </div>

View File

@@ -1,97 +0,0 @@
{% extends "admin_app.html" %}
{% from "library.j2" import form_button %}
{% block content %}
<h1>{% trans %}Manage system{% endtrans %}</h1>
{% if show_metrics %}
<h2>{% trans %}Overall system status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}System load (5 minute average){% endtrans %}</dt>
<dd>
{%- if metrics.load5 -%}
{{ metrics.load5 }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Memory use{% endtrans %}</dt>
<dd>
{%- if metrics.mem_total and metrics.mem_available -%}
{% trans percentage_global=((1 - (metrics.mem_available / metrics.mem_total)) | format_percent), percentage_snikket=((((metrics.prosody_rss | default(0)) + (metrics.portal_rss | default(0))) / metrics.mem_total) | format_percent), mem_available=(metrics.mem_total | format_bytes) %}{{ percentage_global }} of {{ mem_available }}. Of that, Snikket uses {{ percentage_snikket }}.{% endtrans %}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
<h2>{% trans %}Web portal status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}Version{% endtrans %}</dt>
<dd>{{ version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
<dd>
{%- if metrics.portal_cpu -%}
{{ metrics.portal_cpu | format_percent }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Current memory use{% endtrans %}</dt>
<dd>
{%- if metrics.portal_rss -%}
{{ metrics.portal_rss | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
<h2>{% trans %}Snikket server status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}Version{% endtrans %}</dt>
<dd>{{ prosody_version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_cpu -%}
{{ metrics.prosody_cpu | format_percent }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Current memory use{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_rss -%}
{{ metrics.prosody_rss | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Connected devices{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_devices | default(None) is not none -%}
{{ metrics.prosody_devices }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
{% endif %}
<h2>{% trans %}Broadcast message{% endtrans %}</h2>
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
<p class="form-desc">{% trans %}This form allows you to send a message to all users currently online on your Snikket server. Use it wisely.{% endtrans %}</p>
<div class="f-ebox">
{{ form.text.label }}
{{ form.text }}
</div>
<div class="f-ebox">
{{ form.online_only }}{{ form.online_only.label }}
</div>
<div class="f-bbox">
{%- call form_button("send", form.action_send_preview, class="primary") -%}{%- endcall -%}
{%- call form_button("broadcast", form.action_post_all, class="secondary accent") -%}{%- endcall -%}
</div>
</div></form>
{% endblock %}

View File

@@ -1,7 +1,9 @@
{% extends "admin_app.html" %} {% extends "admin_app.html" %}
{% from "library.j2" import action_button, icon, value_or_hint, custom_form_button %} {% from "library.j2" import action_button, value_or_hint, custom_form_button %}
{% block content %} {% block content %}
<h1>{% trans %}Manage users{% endtrans %}</h1> <h1>{% trans %}Manage users{% endtrans %}</h1>
<form method="POST" action="{{ url_for(".create_password_reset_link") }}">
{{- reset_form.csrf_token -}}
<div class="elevated el-2"><table> <div class="elevated el-2"><table>
<thead> <thead>
<tr> <tr>
@@ -13,19 +15,17 @@
<tbody> <tbody>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td> <td>{{ user.localpart }}</td>
{{- user.localpart -}}
{%- if user.has_admin_role -%}
<span class="with-tooltip above" data-tooltip="{% trans %}The user is an administrator.{% endtrans %}">{% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %}</span>
{%- endif -%}
{%- if user.has_restricted_role -%}
<span class="with-tooltip above" data-tooltip="{% trans %}The user is restricted.{% endtrans %}">{% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %}</span>
{%- endif -%}
</td>
<td>{% call value_or_hint(user.display_name) %}{% endcall %}</td> <td>{% call value_or_hint(user.display_name) %}{% endcall %}</td>
<td class="nowrap"> <td class="nowrap">
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%} {%- call action_button("delete", url_for(".delete_user", localpart=user.localpart), class="secondary") -%}
{% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %} {% 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 %}
{%- endcall -%} {%- endcall -%}
</form> </form>
</td> </td>
@@ -33,5 +33,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{%- include "admin_create_invite_form.html" -%} </form>
{% endblock %} {% endblock %}

View File

@@ -5,5 +5,5 @@
{% endblock %} {% endblock %}
{% block topbar_right %} {% block topbar_right %}
{{- super() -}} {{- super() -}}
{% call standard_button("logout", url_for("user.logout"), class="tertiary slimmify") %}{% trans %}Log out{% endtrans %}{% endcall %} {% call standard_button("logout", url_for("user.logout"), class="tertiary") %}{% trans %}Log out{% endtrans %}{% endcall %}
{%- endblock %} {%- endblock %}

View File

@@ -5,6 +5,6 @@
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="css/invite.css") }}"> <link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="css/invite.css") }}">
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="mwrap"><div class="filler"></div><main>{% block content %}{% endblock %}</main><div class="filler"></div></div> <div id="mwrap"><main>{% block content %}{% endblock %}</main></div>
{%- include "_footer.html" -%} {%- include "_footer.html" -%}
{% endblock %} {% endblock %}

View File

@@ -15,6 +15,6 @@
{% trans %}Copy address{% endtrans %} {% trans %}Copy address{% endtrans %}
{%- endcall -%} {%- endcall -%}
<p>{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p> <p>{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p>
<p>{% trans login_url=url_for('main.login') %}You can now safely close this page, or log in to the web portal to <a href="{{ login_url }}">manage your account</a>.{% endtrans %}</p> <p>{% trans %}You can now safely close this page.{% endtrans %}</p>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,6 @@
<title>{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }} | Snikket{% endtrans %}</title> <title>{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }} | Snikket{% endtrans %}</title>
<script async type="text/javascript" src="{{ url_for("static", filename="js/invite-magic.js") }}"></script> <script async type="text/javascript" src="{{ url_for("static", filename="js/invite-magic.js") }}"></script>
<script async type="text/javascript" src="{{ url_for("static", filename="js/qrcode.min.js") }}"></script> <script async type="text/javascript" src="{{ url_for("static", filename="js/qrcode.min.js") }}"></script>
<link rel="alternate" href="{{ invite.xmpp_uri }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="elevated box el-3"> <div class="elevated box el-3">
@@ -27,12 +26,11 @@
<ul> <ul>
<li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' class="play"/></a></li> <li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' class="play"/></a></li>
{%- if apple_store_url -%} {%- if apple_store_url -%}
<li><a href="{{ apple_store_url }}" class="popover" data-popover-id="apple-popover"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></li> <li><a href="{{ apple_store_url }}"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></li>
{%- endif -%} {%- endif -%}
<li><a href="{{ f_droid_url }}" class="popover" data-popover-id="fdroid-popover"><img alt='{% trans %}Get it on F-Droid{% endtrans %}' src='{{ url_for('static', filename='img/f-droid-badge.png') }}' class="fdroid"/></a></li>
</ul> </ul>
{%- call standard_button("qrcode", "#qr-modal", class="primary", onclick="open_modal(this); return false;") -%} {%- call standard_button("qrcode", "#qr-modal", class="primary", onclick="open_modal(this); return false;") -%}
{% trans %}Send to mobile device{% endtrans %} {% trans %}Not on mobile?{% endtrans %}
{%- endcall -%} {%- endcall -%}
</div> </div>
<p>{% trans %}After installation the app should automatically open and prompt you to create an account. If not, simply click the button below.{% endtrans %}</p> <p>{% trans %}After installation the app should automatically open and prompt you to create an account. If not, simply click the button below.{% endtrans %}</p>
@@ -68,7 +66,7 @@
{#- -#} {#- -#}
<div id="qr-info-url" class="tab-pane active"> <div id="qr-info-url" class="tab-pane active">
<p>{% trans %}Use a <em>QR code</em> scanner on your mobile device to scan the code below:{% endtrans %}</p> <p>{% trans %}Use a <em>QR code</em> scanner on your mobile device to scan the code below:{% endtrans %}</p>
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True, _scheme="https") }}" class="qr"></div> <div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True) }}" class="qr"></div>
</div> </div>
{#- -#} {#- -#}
<div id="qr-info-uri" class="tab-pane"> <div id="qr-info-uri" class="tab-pane">
@@ -85,77 +83,10 @@
{%- endcall -%} {%- endcall -%}
</div> </div>
</div> </div>
{%- if apple_store_url -%}
<div id="apple-popover" class="modal" tabindex="-1" role="dialog" aria-hidden="true" style="display: none;" onclick="close_modal(this); return false;">
<div role="document" class="elevated box el-2" onclick="event.stopPropagation();">
<header class="modal-title">
{#- -#}
<span>{% trans %}Install on iOS{% endtrans %}</span>
{#- -#}
{%- call action_button("close", "#", onclick="close_modal(this.parentNode.parentNode.parentNode); return false;", class="tertiary") -%}
{% trans %}Close{% endtrans %}
{%- endcall -%}
</header>
<p>{% trans %}After downloading Snikket from the App Store, you have to return to this invite link and tap on "Open the app" to proceed.{% endtrans %}</p>
<ol>
<li><p>{% trans %}First download Snikket from the App Store using the button below:{% endtrans %}</p>
<p><a href="{{ apple_store_url }}"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></p>
<li><p>{% trans %}After the installation is complete, you can return to this page and tap the "Open the app" button to continue with the setup:{% endtrans %}</p>
<p>
{%- call standard_button("exit_to_app", invite.xmpp_uri, class="primary") -%}
{% trans %}Open the app{% endtrans %}
{%- endcall -%}
</p></li>
</ol>
{#- -#}
{%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="secondary") -%}
{% trans %}Close{% endtrans %}
{%- endcall -%}
</div>
</div>
{%- endif -%}
<div id="fdroid-popover" class="modal" tabindex="-1" role="dialog" aria-hidden="true" style="display: none;" onclick="close_modal(this); return false;">
<div role="document" class="elevated box el-2" onclick="event.stopPropagation();">
<header class="modal-title">
{#- -#}
<span>{% trans %}Install via F-Droid{% endtrans %}</span>
{#- -#}
{%- call action_button("close", "#", onclick="close_modal(this.parentNode.parentNode.parentNode); return false;", class="tertiary") -%}
{% trans %}Close{% endtrans %}
{%- endcall -%}
</header>
<p>{% trans %}After installing Snikket via F-Droid, you have to return to this invite link and tap on "Open the app" to proceed.{% endtrans %}</p>
<ol>
<li><p>{% trans %}First install Snikket from F-Droid using the button below:{% endtrans %}</p>
<p><a href="{{ f_droid_url }}"><img alt='{% trans %}Install via F-Droid{% endtrans %}' src='{{ url_for('static', filename='img/f-droid-badge.png') }}' class="fdroid"/></a></p></li>
<li><p>{% trans %}After the installation is complete, you can return to this page and tap the "Open the app" button to continue with the setup:{% endtrans %}</p>
<p>
{%- call standard_button("exit_to_app", invite.xmpp_uri, class="primary") -%}
{% trans %}Open the app{% endtrans %}
{%- endcall -%}
</p></li>
</ol>
{#- -#}
{%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="secondary") -%}
{% trans %}Close{% endtrans %}
{%- endcall -%}
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">
var catch_popover = function() {
open_modal(this);
return false;
}
var onload = function() { var onload = function() {
apply_qr_code(document.getElementById("qr-invite-page")); apply_qr_code(document.getElementById("qr-invite-page"));
apply_qr_code(document.getElementById("qr-uri")); apply_qr_code(document.getElementById("qr-uri"));
var popover_as = document.getElementsByClassName("popover");
for (var i = 0; i < popover_as.length; ++i) {
var a = popover_as[i];
a.onclick = catch_popover;
a.href = "#" + a.dataset.popoverId;
}
}; };
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -80,7 +80,7 @@
<div class="box warning">{#- -#} <div class="box warning">{#- -#}
<header>{% trans %}Invalid input{% endtrans %}</header> <header>{% trans %}Invalid input{% endtrans %}</header>
{%- if error_list | length == 1 -%} {%- if error_list | length == 1 -%}
<p>{{ error_list[0] }}</p> <p>{{ error_list[0] }}.</p>
{%- else -%} {%- else -%}
<ul> <ul>
{%- for error in error_list -%} {%- for error in error_list -%}

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "library.j2" import box, form_button, render_errors %} {% from "library.j2" import box, form_button %}
{% set body_id = "login" %} {% set body_id = "login" %}
{% block head_lead %} {% block head_lead %}
<title>{{ _("Snikket Login") }}</title> <title>{{ _("Snikket Login") }}</title>
@@ -9,16 +9,16 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="mwrap"><div class="filler"></div><main><div class="form layout-expanded"> <div id="mwrap"><main><div class="form layout-expanded">
<h1 class="form-title">{{ config["SITE_NAME"] }}</h1> <h1 class="form-title">{{ config["SITE_NAME"] }}</h1>
<p class="form-desc">{{ _("Enter your Snikket address and password to manage your account.") }}</p> <p class="form-desc">{{ _("Enter your Snikket address and password to manage your account.") }}</p>
<form method="POST" action="{{ url_for('.login') }}" name="login" id="login-form" onsubmit="return domainCheck();" data-addressid="{{ form.address.id }}" data-domain="{{ config["SNIKKET_DOMAIN"] }}"> <form method="POST" action="{{ url_for('.login') }}" name="login">
{{ form.csrf_token }} {{ form.csrf_token }}
{% call render_errors(form) %}{% endcall %} {% if form.errors %}
<div class="box alert" role="alert" style="display: none;" id="id-warning"> {% call box("alert", _("Login failed")) %}
<header>{% trans %}Incorrect address{% endtrans %}</header> <p>{{ form.errors.values() | flatten | join(", ")}}</p>
<p>{% trans snikket_domain=config["SNIKKET_DOMAIN"] %}This Snikket service only hosts addresses ending in <em>@{{ snikket_domain }}</em>. Your password was not sent.{% endtrans %}</p> {% endcall %}
</div> {% endif %}
<div class="f-ebox"> <div class="f-ebox">
{{ form.address.label(class="a11y-only") }} {{ form.address.label(class="a11y-only") }}
{{ form.address(placeholder=form.address.label.text) }} {{ form.address(placeholder=form.address.label.text) }}
@@ -31,22 +31,6 @@
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%} {%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
</div> </div>
</from> </from>
<script type="text/javascript"> </div></main></div>
var domainCheck = function() {
var form = document.getElementById("login-form");
var addressId = form.dataset.addressid;
var addressField = document.getElementById(addressId);
var domain = form.dataset.domain;
var address = addressField.value;
var errorBox = document.getElementById("id-warning");
if (address.includes("@") && !address.endsWith(domain)) {
errorBox.style.display = "block";
return false;
}
errorBox.style.display = "none";
return true;
};
</script>
</div></main><div class="filler"></div></div>
{%- include "_footer.html" -%} {%- include "_footer.html" -%}
{% endblock %} {% endblock %}

View File

@@ -7,25 +7,6 @@
<div class="filler"></div> <div class="filler"></div>
{% block topbar_right %}{% endblock %} {% block topbar_right %}{% endblock %}
</div> </div>
<div id="mwrap"> <div id="mwrap"><main>{% block content %}{% endblock %}</main></div>
{#- -#}
<div class="flashbox" id="flashbox">
{%- for category, message in get_flashed_messages(True) -%}
<div class="box {{ category }} el-5" role="alert">
{% if category == "success" %}
<header>{% trans %}Operation successful{% endtrans %}</header>
{% elif category == "alert" %}
<header>{% trans %}Error{% endtrans %}</header>
{% endif %}
<p>{{ message }}</p>
</div>
{%- endfor -%}
</div>
{#- -#}
<main>{% block content %}{% endblock %}</main>
{#- -#}
<div class="filler"></div>
{#- -#}
</div>
{%- include "_footer.html" -%} {%- include "_footer.html" -%}
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,15 @@
{% extends "app.html" %} {% extends "app.html" %}
{% from "library.j2" import standard_button, form_button %} {% from "library.j2" import standard_button, form_button %}
{% block head_lead %}
<title>Snikket Web Portal</title>
{% endblock %}
{% block content %} {% block content %}
<div class="form layout-expanded"><form method="POST"> <div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}Sign out of the Snikket Web Portal{% endtrans %}</h2> <h2 class="form-title">{% trans %}Sign out of the Snikket Web Portal{% endtrans %}</h2>
<p class="form-desc">{% trans %}Click below to log yourself out of the web portal. This does not affect any other connected devices.{% endtrans %}</p> <p class="form-desc">{% trans %}Click below to log yourself out of the web portal. This does not affect any other connected devices.{% endtrans %}</p>
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for("user.index"), class="tertiary") -%} {%- call standard_button("back", url_for("user.index"), class="secondary") -%}
{% trans %}Back{% endtrans %} {% trans %}Back{% endtrans %}
{%- endcall -%} {%- endcall -%}
{%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%} {%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%}

View File

@@ -1,5 +1,8 @@
{% extends "app.html" %} {% extends "app.html" %}
{% from "library.j2" import standard_button, custom_form_button, render_errors %} {% from "library.j2" import standard_button, custom_form_button, render_errors %}
{% block head_lead %}
<title>Snikket Web Portal</title>
{% endblock %}
{% block content %} {% block content %}
<div class="form layout-expanded"><form method="POST"> <div class="form layout-expanded"><form method="POST">
<h1 class="form-title">{% trans %}Change your password{% endtrans %}</h1> <h1 class="form-title">{% trans %}Change your password{% endtrans %}</h1>
@@ -24,7 +27,7 @@
<p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p> <p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p>
</div> </div>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} {%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call custom_form_button("passwd", "", "", class="primary") -%} {%- call custom_form_button("passwd", "", "", class="primary") -%}
{% trans %}Change password{% endtrans %} {% trans %}Change password{% endtrans %}
{%- endcall -%} {%- endcall -%}

View File

@@ -1,11 +1,13 @@
{% extends "app.html" %} {% extends "app.html" %}
{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %} {% from "library.j2" import standard_button, form_button, avatar with context %}
{% block head_lead %}
<title>Snikket Web Portal</title>
{% endblock %}
{% block content %} {% block content %}
<h1>{% trans %}Update your profile{% endtrans %}</h1> <h1>{% trans %}Update your profile{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data"> <div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
<h2 class="form-title">{% trans %}Profile{% endtrans %}</h2> <h2 class="form-title">{% trans %}Profile{% endtrans %}</h2>
{{ form.csrf_token }} {{ form.csrf_token }}
{% call render_errors(form) %}{% endcall %}
<div class="f-ebox"> <div class="f-ebox">
{{ form.nickname.label }} {{ form.nickname.label }}
{{ form.nickname(placeholder=user_info.username) }} {{ form.nickname(placeholder=user_info.username) }}
@@ -14,10 +16,7 @@
{{ form.avatar.label }} {{ form.avatar.label }}
<div class="avatar-wrap"> <div class="avatar-wrap">
{%- call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall -%} {%- call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall -%}
{{ form.avatar(accept="image/png", {{ form.avatar }}
data_maxsize=max_avatar_size,
data_warning_header=avatar_too_big_warning_header,
data_maxsize_warning=avatar_too_big_warning) }}
</div> </div>
</div> </div>
<h3 class="form-title">{% trans %}Visibility{% endtrans %}</h3> <h3 class="form-title">{% trans %}Visibility{% endtrans %}</h3>
@@ -29,27 +28,8 @@
</fieldset> </fieldset>
</div> </div>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} {%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
</div> </div>
<script type="text/javascript">
document.getElementById("{{ form.avatar.id }}").onchange = function() {
var maxsize_s = this.dataset.maxsize;
var maxsize = parseInt(maxsize_s);
var existing_alert = document.getElementById("avatar-alert");
if (existing_alert) {
existing_alert.parentNode.removeChild(existing_alert);
}
if (this.files[0].size > maxsize) {
var warning_header = this.dataset.warningHeader;
var warning_text = this.dataset.maxsizeWarning;
this.setCustomValidity(warning_text);
this.reportValidity();
this.value = null;
} else {
this.setCustomValidity("");
}
};
</script>
</form></div> </form></div>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,27 +2,21 @@ import asyncio
import typing import typing
import quart.flask_patch import quart.flask_patch
from quart import ( from quart import Blueprint, render_template, request, redirect, url_for
Blueprint,
render_template,
request,
redirect,
url_for,
flash,
current_app,
)
import quart.exceptions import quart.exceptions
import wtforms import wtforms
import flask_wtf
from flask_babel import lazy_gettext as _l, _ from flask_babel import lazy_gettext as _l, _
from .infra import client, BaseForm from .infra import client
bp = Blueprint('user', __name__) bp = Blueprint('user', __name__)
class ChangePasswordForm(BaseForm): class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
current_password = wtforms.PasswordField( current_password = wtforms.PasswordField(
_l("Current password"), _l("Current password"),
validators=[wtforms.validators.InputRequired()] validators=[wtforms.validators.InputRequired()]
@@ -38,12 +32,12 @@ class ChangePasswordForm(BaseForm):
validators=[wtforms.validators.InputRequired(), validators=[wtforms.validators.InputRequired(),
wtforms.validators.EqualTo( wtforms.validators.EqualTo(
"new_password", "new_password",
_l("The new passwords must match.") _l("The new passwords must match")
)] )]
) )
class LogoutForm(BaseForm): class LogoutForm(flask_wtf.FlaskForm): # type:ignore
action_signout = wtforms.SubmitField( action_signout = wtforms.SubmitField(
_l("Sign out"), _l("Sign out"),
) )
@@ -56,7 +50,7 @@ _ACCESS_MODEL_CHOICES = [
] ]
class ProfileForm(BaseForm): class ProfileForm(flask_wtf.FlaskForm): # type:ignore
nickname = wtforms.TextField( nickname = wtforms.TextField(
_l("Display name"), _l("Display name"),
) )
@@ -96,29 +90,17 @@ async def change_pw() -> typing.Union[str, quart.Response]:
quart.exceptions.Forbidden): quart.exceptions.Forbidden):
# server refused current password, set an appropriate error # server refused current password, set an appropriate error
form.current_password.errors.append( form.current_password.errors.append(
_("Incorrect password."), _("Incorrect password"),
) )
else: else:
await flash(
_("Password changed"),
"success",
)
return redirect(url_for("user.change_pw")) return redirect(url_for("user.change_pw"))
return await render_template("user_passwd.html", form=form) return await render_template("user_passwd.html", form=form)
EAVATARTOOBIG = _l(
"The chosen avatar is too big. To be able to upload larger "
"avatars, please use the app."
)
@bp.route("/profile", methods=["GET", "POST"]) @bp.route("/profile", methods=["GET", "POST"])
@client.require_session() @client.require_session()
async def profile() -> typing.Union[str, quart.Response]: async def profile() -> typing.Union[str, quart.Response]:
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
form = ProfileForm() form = ProfileForm()
if request.method != "POST": if request.method != "POST":
user_info = await client.get_user_info() user_info = await client.get_user_info()
@@ -132,39 +114,26 @@ async def profile() -> typing.Union[str, quart.Response]:
if form.validate_on_submit(): if form.validate_on_submit():
user_info = await client.get_user_info() user_info = await client.get_user_info()
ok = True
file_info = (await request.files).get(form.avatar.name) file_info = (await request.files).get(form.avatar.name)
if file_info is not None: if file_info is not None:
mimetype = file_info.mimetype mimetype = file_info.mimetype
data = file_info.stream.read() data = file_info.stream.read()
if len(data) > max_avatar_size: if len(data) > 0:
form.avatar.errors.append(EAVATARTOOBIG)
ok = False
elif len(data) > 0:
await client.set_user_avatar(data, mimetype) await client.set_user_avatar(data, mimetype)
if ok: if user_info.get("nickname") != form.nickname.data:
if user_info.get("nickname") != form.nickname.data: await client.set_user_nickname(form.nickname.data)
await client.set_user_nickname(form.nickname.data)
access_model = form.profile_access_model.data access_model = form.profile_access_model.data
await asyncio.gather( await asyncio.gather(
client.set_avatar_access_model(access_model), client.set_avatar_access_model(access_model),
client.set_vcard_access_model(access_model), client.set_vcard_access_model(access_model),
client.set_nickname_access_model(access_model), client.set_nickname_access_model(access_model),
) )
await flash( return redirect(url_for(".profile"))
_("Profile updated"),
"success",
)
return redirect(url_for(".profile"))
return await render_template("user_profile.html", return await render_template("user_profile.html", form=form)
form=form,
max_avatar_size=max_avatar_size,
avatar_too_big_warning_header=_l("Error"),
avatar_too_big_warning=EAVATARTOOBIG)
@bp.route("/logout", methods=["GET", "POST"]) @bp.route("/logout", methods=["GET", "POST"])
@@ -173,12 +142,6 @@ async def logout() -> typing.Union[quart.Response, str]:
form = LogoutForm() form = LogoutForm()
if form.validate_on_submit(): if form.validate_on_submit():
await client.logout() await client.logout()
# No flashing here because we dont collect flashes in the login page
# and itd be weird.
# await flash(
# _("Logged out"),
# "success",
# )
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
return await render_template("user_logout.html", form=form) return await render_template("user_logout.html", form=form)

View File

@@ -207,7 +207,7 @@ def make_avatar_metadata_set_request(
item, item,
"metadata", xmlns=NS_USER_AVATAR_METADATA) "metadata", xmlns=NS_USER_AVATAR_METADATA)
attr: typing.Dict[str, str] = { attr: typing.MutableMapping[str, str] = {
"id": id_, "id": id_,
"bytes": str(size), "bytes": str(size),
"type": mimetype, "type": mimetype,
@@ -217,12 +217,7 @@ def make_avatar_metadata_set_request(
if height is not None: if height is not None:
attr["height"] = str(height) attr["height"] = str(height)
ET.SubElement( ET.SubElement(metadata_wrap, "info", xmlns=NS_USER_AVATAR_METADATA, **attr)
metadata_wrap,
"info",
xmlns=NS_USER_AVATAR_METADATA,
**attr, # type: ignore
)
return req return req

View File

@@ -5,16 +5,13 @@ action/delete:delete
action/logout:logout action/logout:logout
action/login:login action/login:login
action/exit_to_app:exit_to_app action/exit_to_app:exit_to_app
action/lock:lock
communication/qr_code:qrcode communication/qr_code:qrcode
communication/vpn_key:passwd communication/vpn_key:passwd
communication/rss_feed:broadcast
content/add_circle_outline:add content/add_circle_outline:add
content/add_link:create_link content/add_link:create_link
content/remove_circle_outline:remove content/remove_circle_outline:remove
content/content_copy:copy content/content_copy:copy
content/link_off:remove_link content/link_off:remove_link
content/send:send
navigation/arrow_back:back navigation/arrow_back:back
navigation/arrow_forward:forward navigation/arrow_forward:forward
navigation/cancel:cancel navigation/cancel:cancel
@@ -28,4 +25,3 @@ navigation/close:close
image/edit:edit image/edit:edit
action/admin_panel_settings:admin action/admin_panel_settings:admin
content/link:link content/link:link
content/insights:insights