You've already forked snikket-web-portal
Compare commits
137 Commits
v0.1.1
...
feature/ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eb464f428 | ||
|
|
2a6ef3c8f1 | ||
|
|
b5d148458a | ||
|
|
261758b07a | ||
|
|
ff99c9488a | ||
|
|
fe78631039 | ||
|
|
12ddd288bf | ||
|
|
633fb0d084 | ||
|
|
f9690063bc | ||
|
|
65ed50acd3 | ||
|
|
aa04320d70 | ||
|
|
818d50a1bb | ||
|
|
c7ba7985ea | ||
|
|
223d127364 | ||
|
|
3a2c4543c4 | ||
|
|
c307f057b9 | ||
|
|
243d5ba236 | ||
|
|
3d62efccfc | ||
|
|
9d26e39025 | ||
|
|
874f0447ba | ||
|
|
0f2127a672 | ||
|
|
20d84e7dd1 | ||
|
|
a02e66023c | ||
|
|
e7db9cc772 | ||
|
|
e91fb45374 | ||
|
|
531565d55c | ||
|
|
c6307619f9 | ||
|
|
da2668cbbc | ||
|
|
765e3890b4 | ||
|
|
b40a625283 | ||
|
|
8a293985ca | ||
|
|
13b2a76c3d | ||
|
|
28e01c336d | ||
|
|
5fb0b91178 | ||
|
|
b007afc901 | ||
|
|
7f02746f63 | ||
|
|
f2788aeb36 | ||
|
|
536a05b0eb | ||
|
|
e0226d47e3 | ||
|
|
0fe10a44ce | ||
|
|
e892d81815 | ||
|
|
c58ce8450f | ||
|
|
03573d1f05 | ||
|
|
486596f89f | ||
|
|
425b4d4295 | ||
|
|
87de808046 | ||
|
|
05455ac743 | ||
|
|
1e926714cb | ||
|
|
e1602f3140 | ||
|
|
2e89973263 | ||
|
|
a6f1361ddd | ||
|
|
552a3bbd41 | ||
|
|
3f2de1e5bf | ||
|
|
059a10f475 | ||
|
|
a48abacf1d | ||
|
|
ea7ed7c030 | ||
|
|
cca899bd8c | ||
|
|
359e6b4ce2 | ||
|
|
6650dd2046 | ||
|
|
97b4a7be0f | ||
|
|
329916e200 | ||
|
|
3571b8909b | ||
|
|
c6c01b82f5 | ||
|
|
c4b575f091 | ||
|
|
fdb55568ec | ||
|
|
a9a651be09 | ||
|
|
d2069289b0 | ||
|
|
552b5d2940 | ||
|
|
b0f9ae5d57 | ||
|
|
dd4a012612 | ||
|
|
e7aa0a2c45 | ||
|
|
ad229d6700 | ||
|
|
b822000f2e | ||
|
|
a6b67b3fdd | ||
|
|
885db355ab | ||
|
|
c3d5b06313 | ||
|
|
2dd8838852 | ||
|
|
5df2c3945a | ||
|
|
3eb8036ebd | ||
|
|
02ed390cd2 | ||
|
|
2506810b90 | ||
|
|
05d1b42dc4 | ||
|
|
5ef5b93eb9 | ||
|
|
0ff6e00e9d | ||
|
|
c04ac4bee0 | ||
|
|
3e19d42c2a | ||
|
|
03732ac06b | ||
|
|
c70228fed7 | ||
|
|
025172592f | ||
|
|
6de1e5313f | ||
|
|
3083c118a3 | ||
|
|
fa1b13fbdb | ||
|
|
ba30d728f4 | ||
|
|
af87301fa4 | ||
|
|
8ee0b0dd30 | ||
|
|
4a27ef9d72 | ||
|
|
9e9fdaf8d4 | ||
|
|
bdb186ca81 | ||
|
|
4ca9b82bce | ||
|
|
6dbe2c2d5e | ||
|
|
e410aedfef | ||
|
|
1713da61e7 | ||
|
|
53aac690df | ||
|
|
5e4009ca11 | ||
|
|
80860a3ac6 | ||
|
|
e9d479a78b | ||
|
|
aac56f49e9 | ||
|
|
52f0bee006 | ||
|
|
97c91b432d | ||
|
|
60647159f3 | ||
|
|
a21730f136 | ||
|
|
e35ab1b723 | ||
|
|
4de4509fc9 | ||
|
|
93e3b325b1 | ||
|
|
ceecfc861c | ||
|
|
2467e73781 | ||
|
|
2f34d39a09 | ||
|
|
de8589923b | ||
|
|
db3a1ac22f | ||
|
|
b48d130659 | ||
|
|
1aed573eb2 | ||
|
|
d4707196ec | ||
|
|
8a8d4c54bd | ||
|
|
ab534e3a59 | ||
|
|
4c128f1af2 | ||
|
|
8b551a8946 | ||
|
|
182d2301be | ||
|
|
6dba5e3a65 | ||
|
|
713da89445 | ||
|
|
9876e42fb7 | ||
|
|
8b66c5a063 | ||
|
|
ddf9f89d77 | ||
|
|
53e023f9ae | ||
|
|
e4d339627e | ||
|
|
cd3026911b | ||
|
|
d7da16f780 | ||
|
|
8ed0fbec25 |
1
.github/workflows/main.yaml
vendored
1
.github/workflows/main.yaml
vendored
@@ -27,6 +27,7 @@ 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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM debian:buster
|
FROM debian:buster-slim
|
||||||
|
|
||||||
ARG BUILD_SERIES=dev
|
ARG BUILD_SERIES=dev
|
||||||
ARG BUILD_ID=0
|
ARG BUILD_ID=0
|
||||||
@@ -18,6 +18,7 @@ RUN set -eu; \
|
|||||||
python3 python3-pip python3-setuptools python3-wheel \
|
python3 python3-pip python3-setuptools python3-wheel \
|
||||||
libpython3-dev \
|
libpython3-dev \
|
||||||
make build-essential \
|
make build-essential \
|
||||||
|
netcat \
|
||||||
; \
|
; \
|
||||||
pip3 install -r requirements.txt; \
|
pip3 install -r requirements.txt; \
|
||||||
pip3 install -r build-requirements.txt; \
|
pip3 install -r build-requirements.txt; \
|
||||||
@@ -34,5 +35,9 @@ ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
|
|||||||
|
|
||||||
ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
|
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 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"]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pyscss~=1.3
|
pyscss~=1.3
|
||||||
mypy
|
mypy
|
||||||
python-dotenv~=0.15
|
python-dotenv~=0.15
|
||||||
|
types-toml
|
||||||
|
|||||||
@@ -2,4 +2,7 @@
|
|||||||
|
|
||||||
export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN"
|
export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN"
|
||||||
|
|
||||||
exec hypercorn -b "127.0.0.1:5765" 'snikket_web:create_app()'
|
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE-127.0.0.1}"
|
||||||
|
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()'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
aiohttp~=3.6
|
aiohttp~=3.6
|
||||||
quart~=0.11
|
quart~=0.11,<0.15
|
||||||
flask-wtf~=0.14
|
flask-wtf~=0.14
|
||||||
hsluv~=0.0.2
|
hsluv~=0.0.2
|
||||||
flask-babel~=1.0
|
flask-babel~=1.0
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from quart import (
|
|||||||
import environ
|
import environ
|
||||||
|
|
||||||
from . import colour, infra
|
from . import colour, infra
|
||||||
from ._version import version, version_info # noqa:F401
|
from ._version import version # noqa:F401
|
||||||
|
|
||||||
|
|
||||||
async def proc() -> typing.Dict[str, typing.Any]:
|
async def proc() -> typing.Dict[str, typing.Any]:
|
||||||
@@ -145,13 +145,24 @@ 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)))
|
||||||
@@ -164,7 +175,7 @@ def create_app() -> quart.Quart:
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
import runpy
|
import runpy
|
||||||
init_vars = runpy.run_path(env_init) # type:ignore
|
init_vars = runpy.run_path(env_init)
|
||||||
for name, value in init_vars.items():
|
for name, value in init_vars.items():
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
@@ -182,6 +193,8 @@ 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(
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
version_info = (0, 1, 1, "a0")
|
import os
|
||||||
version = (
|
import subprocess
|
||||||
".".join(map(str, version_info[:3])) +
|
|
||||||
(f"-{version_info[3]}" if version_info[3] else "")
|
version = "(unknown)"
|
||||||
)
|
|
||||||
|
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)"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
import resource
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -17,13 +19,14 @@ 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
|
from . import prosodyclient, _version
|
||||||
from .infra import client, circle_name
|
from .infra import client, circle_name, BaseForm
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
@@ -31,11 +34,14 @@ 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:
|
||||||
return await render_template("admin_home.html")
|
show_metrics = current_app.config["SHOW_METRICS"]
|
||||||
|
return await render_template(
|
||||||
|
"admin_home.html",
|
||||||
|
show_metrics=show_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetLinkPost(flask_wtf.FlaskForm): # type: ignore
|
class PasswordResetLinkPost(BaseForm):
|
||||||
action_create = wtforms.StringField()
|
|
||||||
action_revoke = wtforms.StringField()
|
action_revoke = wtforms.StringField()
|
||||||
|
|
||||||
|
|
||||||
@@ -46,15 +52,99 @@ 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 DeleteUserForm(flask_wtf.FlaskForm): # type:ignore
|
_LIMITED_ROLE_NAME = _("Limited")
|
||||||
|
|
||||||
|
|
||||||
|
class EditUserForm(BaseForm):
|
||||||
|
localpart = wtforms.StringField(
|
||||||
|
_l("Login name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = wtforms.StringField(
|
||||||
|
_l("Display name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
role = wtforms.RadioField(
|
||||||
|
_l("Access Level"),
|
||||||
|
choices=[
|
||||||
|
# NOTE: enable this only after something has been done which
|
||||||
|
# actually enforces the described restrictions :).
|
||||||
|
# ("prosody:restricted", _LIMITED_ROLE_NAME),
|
||||||
|
("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")
|
||||||
)
|
)
|
||||||
@@ -68,6 +158,10 @@ 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(
|
||||||
@@ -93,37 +187,47 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/password-reset/-", methods=["POST"])
|
@bp.route("/users/password-reset/<id_>", methods=["GET", "POST"])
|
||||||
@client.require_admin_session()
|
@client.require_admin_session()
|
||||||
async def create_password_reset_link() -> typing.Union[str, quart.Response]:
|
async def user_password_reset_link(
|
||||||
form = PasswordResetLinkPost()
|
id_: str,
|
||||||
if not form.validate_on_submit():
|
) -> typing.Union[str, quart.Response]:
|
||||||
abort(400)
|
invite_info = await client.get_invite_by_id(
|
||||||
|
id_,
|
||||||
if form.action_create.data:
|
)
|
||||||
localpart = form.action_create.data
|
if invite_info.jid is None:
|
||||||
target_user_info = await client.get_user_by_localpart(localpart)
|
await flash(
|
||||||
reset_link = await client.create_password_reset_invite(
|
_("Password reset link not found"),
|
||||||
localpart=localpart,
|
"alert",
|
||||||
ttl=86400,
|
|
||||||
)
|
)
|
||||||
elif form.action_revoke.data:
|
|
||||||
await client.delete_invite(form.action_revoke.data)
|
|
||||||
return redirect(url_for(".users"))
|
return redirect(url_for(".users"))
|
||||||
|
|
||||||
|
localpart = prosodyclient.split_jid(invite_info.jid)[0]
|
||||||
|
|
||||||
|
form = PasswordResetLinkPost()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if form.action_revoke.data:
|
||||||
|
await client.delete_invite(id_)
|
||||||
|
await flash(
|
||||||
|
_("Password reset link deleted"),
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for(".edit_user", localpart=localpart))
|
||||||
|
abort(400)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin_reset_user_password.html",
|
"admin_reset_user_password.html",
|
||||||
target_user=target_user_info,
|
localpart=localpart,
|
||||||
reset_link=reset_link,
|
reset_link=invite_info,
|
||||||
form=form,
|
form=form,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvitesListForm(flask_wtf.FlaskForm): # type:ignore
|
class InvitesListForm(BaseForm):
|
||||||
action_revoke = wtforms.StringField()
|
action_revoke = wtforms.StringField()
|
||||||
|
|
||||||
|
|
||||||
class InvitePost(flask_wtf.FlaskForm): # type:ignore
|
class InvitePost(BaseForm):
|
||||||
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.
|
||||||
@@ -217,7 +321,7 @@ async def invitations() -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InviteForm(flask_wtf.FlaskForm): # type:ignore
|
class InviteForm(BaseForm):
|
||||||
action_revoke = wtforms.SubmitField(
|
action_revoke = wtforms.SubmitField(
|
||||||
_l("Revoke")
|
_l("Revoke")
|
||||||
)
|
)
|
||||||
@@ -242,6 +346,10 @@ 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)
|
||||||
@@ -254,7 +362,11 @@ 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:
|
||||||
abort(404)
|
await flash(
|
||||||
|
_("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
|
||||||
@@ -265,6 +377,10 @@ 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_))
|
||||||
|
|
||||||
@@ -277,7 +393,7 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CirclePost(flask_wtf.FlaskForm): # type:ignore
|
class CirclePost(BaseForm):
|
||||||
name = wtforms.StringField(
|
name = wtforms.StringField(
|
||||||
_l("Name"),
|
_l("Name"),
|
||||||
validators=[wtforms.validators.InputRequired()],
|
validators=[wtforms.validators.InputRequired()],
|
||||||
@@ -313,6 +429,10 @@ 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(
|
||||||
@@ -321,7 +441,7 @@ async def create_circle() -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EditCircleForm(flask_wtf.FlaskForm): # type:ignore
|
class EditCircleForm(BaseForm):
|
||||||
name = wtforms.StringField(
|
name = wtforms.StringField(
|
||||||
_l("Name"),
|
_l("Name"),
|
||||||
validators=[wtforms.validators.InputRequired()],
|
validators=[wtforms.validators.InputRequired()],
|
||||||
@@ -358,24 +478,28 @@ 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 = sorted(
|
users = {
|
||||||
await client.list_users(),
|
user.localpart: user
|
||||||
key=lambda x: x.localpart
|
for user in await client.list_users()
|
||||||
)
|
}
|
||||||
circle_members = [
|
circle_members = [
|
||||||
user for user in users
|
(localpart, users.get(localpart))
|
||||||
if user.localpart in circle.members
|
for localpart in sorted(circle.members)
|
||||||
]
|
]
|
||||||
|
|
||||||
form = EditCircleForm()
|
form = EditCircleForm()
|
||||||
form.user_to_add.choices = [
|
form.user_to_add.choices = sorted(
|
||||||
(user.localpart, user.localpart)
|
(localpart, localpart)
|
||||||
for user in users
|
for localpart in users.keys()
|
||||||
if user.localpart not in circle.members
|
if 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()
|
||||||
@@ -391,21 +515,36 @@ 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:
|
else:
|
||||||
@@ -418,3 +557,148 @@ 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import itertools
|
import itertools
|
||||||
|
import math
|
||||||
import secrets
|
import secrets
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ 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
|
||||||
@@ -21,11 +23,20 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -41,12 +52,27 @@ 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)
|
||||||
|
|
||||||
@@ -55,3 +81,14 @@ 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)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ 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
|
from .infra import client, selected_locale, BaseForm
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("invite", __name__)
|
bp = Blueprint("invite", __name__)
|
||||||
@@ -48,14 +47,20 @@ def context() -> typing.Mapping[str, typing.Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/<id_>")
|
@bp.route("/<id_>")
|
||||||
|
async def view_old(id_: str) -> quart.Response:
|
||||||
|
return redirect(url_for(".view", id_=id_))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<id_>/")
|
@bp.route("/<id_>/")
|
||||||
async def view(id_: str) -> str:
|
async def view(id_: str) -> typing.Union[quart.Response,
|
||||||
|
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")
|
return await render_template("invite_invalid.html"), 404
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if invite.reset_localpart is not None:
|
if invite.reset_localpart is not None:
|
||||||
@@ -80,16 +85,23 @@ async def view(id_: str) -> str:
|
|||||||
)
|
)
|
||||||
apple_store_url = current_app.config["APPLE_STORE_URL"]
|
apple_store_url = current_app.config["APPLE_STORE_URL"]
|
||||||
|
|
||||||
return await render_template(
|
body = 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(flask_wtf.FlaskForm): # type:ignore
|
class RegisterForm(BaseForm):
|
||||||
localpart = wtforms.StringField(
|
localpart = wtforms.StringField(
|
||||||
_l("Username"),
|
_l("Username"),
|
||||||
)
|
)
|
||||||
@@ -103,7 +115,7 @@ class RegisterForm(flask_wtf.FlaskForm): # type:ignore
|
|||||||
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.")
|
||||||
)]
|
)]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,15 +147,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_))
|
||||||
@@ -160,7 +172,7 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResetForm(flask_wtf.FlaskForm): # type:ignore
|
class ResetForm(BaseForm):
|
||||||
password = wtforms.PasswordField(
|
password = wtforms.PasswordField(
|
||||||
_l("Password"),
|
_l("Password"),
|
||||||
)
|
)
|
||||||
@@ -170,7 +182,7 @@ class ResetForm(flask_wtf.FlaskForm): # type:ignore
|
|||||||
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.")
|
||||||
)]
|
)]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -203,7 +215,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_))
|
||||||
|
|||||||
@@ -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
|
from .infra import client, BaseForm
|
||||||
|
|
||||||
|
|
||||||
bp = quart.Blueprint("main", __name__)
|
bp = quart.Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(flask_wtf.FlaskForm): # type:ignore
|
class LoginForm(BaseForm):
|
||||||
address = wtforms.TextField(
|
address = wtforms.TextField(
|
||||||
_l("Address"),
|
_l("Address"),
|
||||||
validators=[wtforms.validators.InputRequired()],
|
validators=[wtforms.validators.InputRequired()],
|
||||||
@@ -52,6 +52,9 @@ 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()):
|
||||||
@@ -63,16 +66,24 @@ 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"]
|
||||||
jid = "{}@{}".format(localpart, domain)
|
if domain != current_app.config["SNIKKET_DOMAIN"]:
|
||||||
password = form.password.data
|
# (a) prosody throws a 400 at us and I prefer to catch that here
|
||||||
try:
|
# and (b) I don’t want to pass on this obviously not-for-here
|
||||||
await client.login(jid, password)
|
# password further than necessary.
|
||||||
except quart.exceptions.Unauthorized:
|
form.password.errors.append(ERR_CREDENTIALS_INVALID)
|
||||||
form.password.errors.append(
|
|
||||||
_("Invalid username or password.")
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return redirect(url_for('user.index'))
|
jid = "{}@{}".format(localpart, domain)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -89,6 +100,10 @@ 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",
|
||||||
@@ -108,6 +123,7 @@ 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:
|
||||||
@@ -147,3 +163,8 @@ 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")
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ 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(
|
||||||
@@ -55,6 +64,7 @@ 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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -332,15 +342,18 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
http_session[self.SESSION_TOKEN] = token_info.token
|
self._store_token_in_session(token_info)
|
||||||
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
|
||||||
@@ -445,6 +458,13 @@ 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(
|
||||||
@@ -490,9 +510,32 @@ class ProsodyClient:
|
|||||||
"to": self.session_address,
|
"to": self.session_address,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with session.post(self._rest_endpoint, data=req) as resp:
|
async with session.post(self._rest_endpoint, json=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,
|
||||||
@@ -767,7 +810,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 = await self._oauth2_bearer_token(
|
token_info = await self._oauth2_bearer_token(
|
||||||
session,
|
session,
|
||||||
self.session_address,
|
self.session_address,
|
||||||
current_password,
|
current_password,
|
||||||
@@ -779,14 +822,14 @@ class ProsodyClient:
|
|||||||
new_password
|
new_password
|
||||||
),
|
),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": "Bearer {}".format(token),
|
"Authorization": "Bearer {}".format(token_info.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.
|
||||||
http_session[self.SESSION_TOKEN] = token
|
self._store_token_in_session(token_info)
|
||||||
|
|
||||||
def _raise_error_from_response(
|
def _raise_error_from_response(
|
||||||
self,
|
self,
|
||||||
@@ -825,6 +868,29 @@ 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,
|
||||||
@@ -1109,3 +1175,41 @@ 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()
|
||||||
|
|||||||
@@ -252,3 +252,4 @@ $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;
|
||||||
|
|||||||
@@ -33,13 +33,35 @@ 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 */
|
||||||
@@ -332,6 +354,15 @@ 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;
|
||||||
@@ -995,6 +1026,23 @@ 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 */
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ 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;
|
||||||
@@ -74,6 +76,10 @@ 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;
|
||||||
|
|||||||
BIN
snikket_web/static/img/f-droid-badge.png
Normal file
BIN
snikket_web/static/img/f-droid-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -37,6 +37,11 @@ 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>
|
||||||
@@ -47,6 +52,12 @@ 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" />
|
||||||
@@ -72,6 +83,11 @@ 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" />
|
||||||
@@ -137,4 +153,9 @@ 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: 14 KiB After Width: | Height: | Size: 16 KiB |
@@ -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>About Snikket</title>
|
<title>{% trans %}About Snikket{% endtrans %}</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -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(".index"), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
{%- call standard_button("back", url_for(".edit_user", localpart=target_user.localpart), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||||
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
|
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
@@ -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 %}
|
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon %}
|
||||||
{% 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="secondary") -%}
|
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
|
||||||
{% trans %}Back{% endtrans %}
|
{% trans %}Return to circle list{% 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,14 +56,21 @@
|
|||||||
{%- if circle_members -%}
|
{%- if circle_members -%}
|
||||||
<div class="el-2 elevated"><table>
|
<div class="el-2 elevated"><table>
|
||||||
<thead>
|
<thead>
|
||||||
<th>Login name</th>
|
<th>{% trans %}Login name{% endtrans %}</th>
|
||||||
<th class="collapsible">Display name</th>
|
<th class="collapsible">{% trans %}Display name{% endtrans %}</th>
|
||||||
<th>Actions</th>
|
<th>{% trans %}Actions{% endtrans %}</th>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{%- for member in circle_members -%}
|
{%- for localpart, member in circle_members -%}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ member.localpart }}</td>
|
<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) -%}
|
||||||
|
|||||||
@@ -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 form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%}
|
{%- call standard_button("back", url_for(".invitations"), class="tertiary") %}
|
||||||
{%- call standard_button("back", url_for(".invitations"), class="primary") %}
|
{% trans %}Return to invitation list{% endtrans %}
|
||||||
{% trans %}Back{% endtrans %}
|
|
||||||
{%- endcall %}
|
{%- endcall %}
|
||||||
|
{%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
78
snikket_web/templates/admin_edit_user.html
Normal file
78
snikket_web/templates/admin_edit_user.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -31,6 +31,18 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<col/>
|
<col/>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans %}Valid until{% endtrans %}</th>
|
<th>{% trans %}Expires{% 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>
|
||||||
|
|||||||
@@ -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=target_user.localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2>
|
<h2 class="form-title">{% trans user_name=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(".users"), class="primary") -%}
|
{%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%}
|
||||||
{% trans %}Back{% endtrans %}
|
{% trans %}Back{% endtrans %}
|
||||||
{%- endcall -%}
|
{%- endcall -%}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
97
snikket_web/templates/admin_system.html
Normal file
97
snikket_web/templates/admin_system.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
{% extends "admin_app.html" %}
|
{% extends "admin_app.html" %}
|
||||||
{% from "library.j2" import action_button, value_or_hint, custom_form_button %}
|
{% from "library.j2" import action_button, icon, value_or_hint, custom_form_button %}
|
||||||
{% block content %}
|
{% 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>
|
||||||
@@ -15,17 +13,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user.localpart }}</td>
|
<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("delete", url_for(".delete_user", localpart=user.localpart), class="secondary") -%}
|
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%}
|
||||||
{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}
|
{% trans user_name=user.localpart %}Edit 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>
|
||||||
</form>
|
{%- include "admin_create_invite_form.html" -%}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block topbar_right %}
|
{% block topbar_right %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
{% call standard_button("logout", url_for("user.logout"), class="tertiary") %}{% trans %}Log out{% endtrans %}{% endcall %}
|
{% call standard_button("logout", url_for("user.logout"), class="tertiary slimmify") %}{% trans %}Log out{% endtrans %}{% endcall %}
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|||||||
@@ -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"><main>{% block content %}{% endblock %}</main></div>
|
<div id="mwrap"><div class="filler"></div><main>{% block content %}{% endblock %}</main><div class="filler"></div></div>
|
||||||
{%- include "_footer.html" -%}
|
{%- include "_footer.html" -%}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}You can now safely close this page.{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<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">
|
||||||
@@ -26,11 +27,12 @@
|
|||||||
<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 }}"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></li>
|
<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>
|
||||||
{%- 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 %}Not on mobile?{% endtrans %}
|
{% trans %}Send to mobile device{% 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>
|
||||||
@@ -66,7 +68,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) }}" class="qr"></div>
|
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True, _scheme="https") }}" class="qr"></div>
|
||||||
</div>
|
</div>
|
||||||
{#- -#}
|
{#- -#}
|
||||||
<div id="qr-info-uri" class="tab-pane">
|
<div id="qr-info-uri" class="tab-pane">
|
||||||
@@ -83,10 +85,77 @@
|
|||||||
{%- 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 %}
|
||||||
|
|||||||
@@ -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 -%}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "library.j2" import box, form_button %}
|
{% from "library.j2" import box, form_button, render_errors %}
|
||||||
{% 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"><main><div class="form layout-expanded">
|
<div id="mwrap"><div class="filler"></div><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">
|
<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.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
{% if form.errors %}
|
{% call render_errors(form) %}{% endcall %}
|
||||||
{% call box("alert", _("Login failed")) %}
|
<div class="box alert" role="alert" style="display: none;" id="id-warning">
|
||||||
<p>{{ form.errors.values() | flatten | join(", ")}}</p>
|
<header>{% trans %}Incorrect address{% endtrans %}</header>
|
||||||
{% endcall %}
|
<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>
|
||||||
{% endif %}
|
</div>
|
||||||
<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,6 +31,22 @@
|
|||||||
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
|
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
|
||||||
</div>
|
</div>
|
||||||
</from>
|
</from>
|
||||||
</div></main></div>
|
<script type="text/javascript">
|
||||||
|
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 %}
|
||||||
|
|||||||
@@ -7,6 +7,25 @@
|
|||||||
<div class="filler"></div>
|
<div class="filler"></div>
|
||||||
{% block topbar_right %}{% endblock %}
|
{% block topbar_right %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div id="mwrap"><main>{% block content %}{% endblock %}</main></div>
|
<div id="mwrap">
|
||||||
|
{#- -#}
|
||||||
|
<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 %}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
{% 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="secondary") -%}
|
{%- call standard_button("back", url_for("user.index"), class="tertiary") -%}
|
||||||
{% 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 -%}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{% 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>
|
||||||
@@ -27,7 +24,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="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||||
{%- call custom_form_button("passwd", "", "", class="primary") -%}
|
{%- call custom_form_button("passwd", "", "", class="primary") -%}
|
||||||
{% trans %}Change password{% endtrans %}
|
{% trans %}Change password{% endtrans %}
|
||||||
{%- endcall -%}
|
{%- endcall -%}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
{% extends "app.html" %}
|
{% extends "app.html" %}
|
||||||
{% from "library.j2" import standard_button, form_button, avatar with context %}
|
{% from "library.j2" import standard_button, form_button, render_errors, 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) }}
|
||||||
@@ -16,7 +14,10 @@
|
|||||||
{{ 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 }}
|
{{ form.avatar(accept="image/png",
|
||||||
|
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>
|
||||||
@@ -28,8 +29,27 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-bbox">
|
<div class="f-bbox">
|
||||||
{%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||||
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
|
{%- 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 %}
|
||||||
|
|||||||
BIN
snikket_web/translations/da/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/da/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
1552
snikket_web/translations/da/LC_MESSAGES/messages.po
Normal file
1552
snikket_web/translations/da/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/de/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/de/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/en/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/en/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/en_GB/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/en_GB/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/es_MX/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/es_MX/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
1518
snikket_web/translations/es_MX/LC_MESSAGES/messages.po
Normal file
1518
snikket_web/translations/es_MX/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/fr/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/fr/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/id/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/id/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/it/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/it/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
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
BIN
snikket_web/translations/pl/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/pl/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
snikket_web/translations/sv/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/sv/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
1551
snikket_web/translations/sv/LC_MESSAGES/messages.po
Normal file
1551
snikket_web/translations/sv/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,21 +2,27 @@ import asyncio
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
import quart.flask_patch
|
import quart.flask_patch
|
||||||
from quart import Blueprint, render_template, request, redirect, url_for
|
from quart import (
|
||||||
|
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
|
from .infra import client, BaseForm
|
||||||
|
|
||||||
bp = Blueprint('user', __name__)
|
bp = Blueprint('user', __name__)
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
class ChangePasswordForm(BaseForm):
|
||||||
current_password = wtforms.PasswordField(
|
current_password = wtforms.PasswordField(
|
||||||
_l("Current password"),
|
_l("Current password"),
|
||||||
validators=[wtforms.validators.InputRequired()]
|
validators=[wtforms.validators.InputRequired()]
|
||||||
@@ -32,12 +38,12 @@ class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
|||||||
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(flask_wtf.FlaskForm): # type:ignore
|
class LogoutForm(BaseForm):
|
||||||
action_signout = wtforms.SubmitField(
|
action_signout = wtforms.SubmitField(
|
||||||
_l("Sign out"),
|
_l("Sign out"),
|
||||||
)
|
)
|
||||||
@@ -50,7 +56,7 @@ _ACCESS_MODEL_CHOICES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ProfileForm(flask_wtf.FlaskForm): # type:ignore
|
class ProfileForm(BaseForm):
|
||||||
nickname = wtforms.TextField(
|
nickname = wtforms.TextField(
|
||||||
_l("Display name"),
|
_l("Display name"),
|
||||||
)
|
)
|
||||||
@@ -90,17 +96,29 @@ 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()
|
||||||
@@ -114,26 +132,40 @@ 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) > 0:
|
if len(data) > max_avatar_size:
|
||||||
|
print(len(data), max_avatar_size)
|
||||||
|
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 user_info.get("nickname") != form.nickname.data:
|
if ok:
|
||||||
await client.set_user_nickname(form.nickname.data)
|
if user_info.get("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),
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect(url_for(".profile"))
|
await flash(
|
||||||
|
_("Profile updated"),
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for(".profile"))
|
||||||
|
|
||||||
return await render_template("user_profile.html", form=form)
|
return await render_template("user_profile.html",
|
||||||
|
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"])
|
||||||
@@ -142,6 +174,12 @@ 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 don’t collect flashes in the login page
|
||||||
|
# and it’d 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)
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ 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
|
||||||
@@ -25,3 +28,4 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user