You've already forked snikket-web-portal
Compare commits
214 Commits
feature/ci
...
add-servic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fda822e9d9 | ||
|
|
846a5e49fd | ||
|
|
b3ff7f04b5 | ||
|
|
0ac4ab8142 | ||
|
|
d4a38f5049 | ||
|
|
344a4d3e93 | ||
|
|
57f1047526 | ||
|
|
b036caa85e | ||
|
|
08845cb9f0 | ||
|
|
6aa6e12680 | ||
|
|
4bd58c1104 | ||
|
|
4f7a4fb5d4 | ||
|
|
34a23f8505 | ||
|
|
ebcb083b6a | ||
|
|
2f0b38b149 | ||
|
|
6244ad5c8a | ||
|
|
07fa1f0abd | ||
|
|
3d22458f9b | ||
|
|
3b768fe220 | ||
|
|
19cf82e894 | ||
|
|
fe0316708b | ||
|
|
81b0a58dc9 | ||
|
|
08aea153f9 | ||
|
|
958b3365f7 | ||
|
|
05caf38d37 | ||
|
|
390ecded42 | ||
|
|
f6395d4d9c | ||
|
|
32179c72cd | ||
|
|
3cb8185b1a | ||
|
|
481379d03f | ||
|
|
275b302531 | ||
|
|
e18f727db0 | ||
|
|
f7429413cd | ||
|
|
d5a46b69a6 | ||
|
|
51f2ebbd13 | ||
|
|
b4e6ee8943 | ||
|
|
52d8047546 | ||
|
|
aed9ad1cde | ||
|
|
b545c137b1 | ||
|
|
47642dc384 | ||
|
|
5d7183a0b8 | ||
|
|
c1cf6ab1e5 | ||
|
|
aee53a2e1a | ||
|
|
3a81a0140b | ||
|
|
5b4d4ddd36 | ||
|
|
28ff19c19c | ||
|
|
8e3837f704 | ||
|
|
4af78f635e | ||
|
|
98e7de3166 | ||
|
|
61c71b2145 | ||
|
|
6b35e9a259 | ||
|
|
58c2112fec | ||
|
|
c856afee82 | ||
|
|
c8356a8e9e | ||
|
|
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 | ||
|
|
5b812c773d | ||
|
|
fa61ee4e11 | ||
|
|
7402480c62 | ||
|
|
a68a469319 | ||
|
|
961f285fa5 | ||
|
|
7456295cb6 | ||
|
|
96f4b0d4f8 | ||
|
|
245434126e | ||
|
|
725dffc458 | ||
|
|
22783b837e | ||
|
|
ba18fe692f | ||
|
|
387a989caa | ||
|
|
ffab48cff0 | ||
|
|
17bf7cb140 | ||
|
|
408d837a0f | ||
|
|
56e1083ada | ||
|
|
2aa3d629da | ||
|
|
6779341db3 | ||
|
|
2db6cbe6fd | ||
|
|
9bc6e0b555 | ||
|
|
98a3eeba7c | ||
|
|
de97b08f01 | ||
|
|
f2dc970731 |
3
.github/workflows/main.yaml
vendored
3
.github/workflows/main.yaml
vendored
@@ -27,6 +27,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
pip install mypy
|
||||
pip install -r requirements.txt
|
||||
pip install -r build-requirements.txt
|
||||
- name: Typecheck
|
||||
run: |
|
||||
python -m mypy --config mypy.ini -p snikket_web
|
||||
@@ -44,7 +45,7 @@ jobs:
|
||||
- name: Install
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pip install flake8
|
||||
pip install flake8 flake8-print
|
||||
- name: Linting
|
||||
run: |
|
||||
python -m flake8 snikket_web
|
||||
|
||||
63
Dockerfile
63
Dockerfile
@@ -1,54 +1,59 @@
|
||||
FROM debian:buster
|
||||
|
||||
ARG BUILD_SERIES=dev
|
||||
ARG BUILD_ID=0
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
|
||||
# This Dockerfile attempts to strike a balance between image size and time it
|
||||
# takes to do an incremental build on changes.
|
||||
# Improvements welcome.
|
||||
FROM debian:bullseye-slim AS build
|
||||
|
||||
RUN set -eu; \
|
||||
export DEBIAN_FRONTEND=noninteractive ; \
|
||||
apt-get update ; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-setuptools python3-wheel \
|
||||
libpython3-dev \
|
||||
make build-essential \
|
||||
; \
|
||||
apt-get clean ; rm -rf /var/lib/apt/lists
|
||||
make build-essential;
|
||||
|
||||
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
|
||||
COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt
|
||||
COPY Makefile /opt/snikket-web-portal/Makefile
|
||||
COPY snikket_web/ /opt/snikket-web-portal/snikket_web
|
||||
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
|
||||
|
||||
WORKDIR /opt/snikket-web-portal
|
||||
|
||||
RUN set -eu; \
|
||||
pip3 install -r requirements.txt; \
|
||||
pip3 install -r build-requirements.txt; \
|
||||
rm -rf /root/.cache;
|
||||
make;
|
||||
|
||||
COPY Makefile /opt/snikket-web-portal/Makefile
|
||||
COPY snikket_web/ /opt/snikket-web-portal/snikket_web
|
||||
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
|
||||
|
||||
# NOTE: abusing true(1) as a terrible way to disable a specific command. If
|
||||
# one merged all the RUN commands into one, one would want to run the
|
||||
# uninstall/remove commands there, but with the split up RUN commands it is
|
||||
# rather pointless.
|
||||
RUN set -eu; \
|
||||
make; \
|
||||
true pip3 uninstall -yr build-requirements.txt; \
|
||||
true apt-get remove -y build-essential make libpython3-dev; \
|
||||
true apt-get autoremove -y; \
|
||||
pip3 install hypercorn; \
|
||||
rm -rf /root/.cache; \
|
||||
apt-get clean ; rm -rf /var/lib/apt/lists
|
||||
FROM debian:bullseye-slim
|
||||
|
||||
ARG BUILD_SERIES=dev
|
||||
ARG BUILD_ID=0
|
||||
|
||||
COPY docker/env.py /etc/snikket-web-portal/env.py
|
||||
|
||||
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
|
||||
|
||||
ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
|
||||
|
||||
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
|
||||
|
||||
WORKDIR /opt/snikket-web-portal
|
||||
|
||||
RUN set -eu; \
|
||||
export DEBIAN_FRONTEND=noninteractive ; \
|
||||
apt-get update ; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-setuptools python3-wheel build-essential libpython3-dev netcat; \
|
||||
pip3 install -r requirements.txt; \
|
||||
apt-get remove -y --autoremove build-essential libpython3-dev; \
|
||||
apt-get clean ; rm -rf /var/lib/apt/lists; \
|
||||
pip3 install hypercorn; \
|
||||
rm -rf /root/.cache;
|
||||
|
||||
HEALTHCHECK CMD nc -zv ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE:-127.0.0.1} ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT:-5765}
|
||||
|
||||
COPY --from=build /opt/snikket-web-portal/snikket_web/ /opt/snikket-web-portal/snikket_web
|
||||
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
|
||||
|
||||
RUN echo "$BUILD_SERIES $BUILD_ID" > /opt/snikket-web-portal/.app_version
|
||||
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pyscss~=1.3
|
||||
mypy
|
||||
python-dotenv~=0.15
|
||||
types-toml
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
|
||||
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()'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 421 KiB |
@@ -1,8 +1,9 @@
|
||||
aiohttp~=3.6
|
||||
quart~=0.11
|
||||
quart~=0.11,<0.15
|
||||
flask-wtf~=0.14
|
||||
hsluv~=0.0.2
|
||||
flask-babel~=1.0
|
||||
email-validator~=1.1
|
||||
environ-config~=20.0
|
||||
wtforms~=2.3
|
||||
typing-extensions
|
||||
|
||||
@@ -21,7 +21,7 @@ from quart import (
|
||||
import environ
|
||||
|
||||
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]:
|
||||
@@ -48,6 +48,7 @@ async def proc() -> typing.Dict[str, typing.Any]:
|
||||
"text_to_css": colour.text_to_css,
|
||||
"lang": infra.selected_locale(),
|
||||
"user_info": user_info,
|
||||
"is_in_debug_mode": current_app.debug,
|
||||
}
|
||||
|
||||
|
||||
@@ -144,13 +145,27 @@ class AppConfig:
|
||||
site_name = environ.var("")
|
||||
avatar_cache_ttl = environ.var(1800, converter=int)
|
||||
languages = environ.var([
|
||||
"da",
|
||||
"de",
|
||||
"en",
|
||||
"fr",
|
||||
"id",
|
||||
"po",
|
||||
"it",
|
||||
"pl",
|
||||
"sv",
|
||||
], 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)
|
||||
retention_days = environ.var(7, converter=int, name="SNIKKET_RETENTION_DAYS")
|
||||
operator_name = environ.var(None, name="SNIKKET_OPERATOR_NAME")
|
||||
provider_name = environ.var(None, name="SNIKKET_PROVIDER_NAME")
|
||||
|
||||
|
||||
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
|
||||
@@ -163,7 +178,7 @@ def create_app() -> quart.Quart:
|
||||
pass
|
||||
else:
|
||||
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():
|
||||
if not name:
|
||||
continue
|
||||
@@ -181,6 +196,11 @@ def create_app() -> quart.Quart:
|
||||
app.config["SITE_NAME"] = config.site_name or config.domain
|
||||
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
|
||||
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.config["RETENTION_DAYS"] = config.retention_days
|
||||
app.config["OPERATOR_NAME"] = config.operator_name
|
||||
app.config["PROVIDER_NAME"] = config.provider_name
|
||||
|
||||
app.context_processor(proc)
|
||||
app.register_error_handler(
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
version_info = (0, 1, 0, "a0")
|
||||
version = (
|
||||
".".join(map(str, version_info[:3])) +
|
||||
(f"-{version_info[3]}" if version_info[3] else "")
|
||||
)
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
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,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import resource
|
||||
import time
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
@@ -18,13 +19,14 @@ from quart import (
|
||||
url_for,
|
||||
request,
|
||||
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 .infra import client, circle_name
|
||||
from . import prosodyclient, _version
|
||||
from .infra import client, circle_name, BaseForm
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
@@ -32,11 +34,14 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@bp.route("/")
|
||||
@client.require_admin_session()
|
||||
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
|
||||
action_create = wtforms.StringField()
|
||||
class PasswordResetLinkPost(BaseForm):
|
||||
action_revoke = wtforms.StringField()
|
||||
|
||||
|
||||
@@ -47,15 +52,94 @@ async def users() -> str:
|
||||
await client.list_users(),
|
||||
key=lambda x: x.localpart
|
||||
)
|
||||
invite_form = InvitePost()
|
||||
await invite_form.init_choices()
|
||||
reset_form = PasswordResetLinkPost()
|
||||
return await render_template(
|
||||
"admin_users.html",
|
||||
users=users,
|
||||
reset_form=reset_form,
|
||||
invite_form=invite_form,
|
||||
)
|
||||
|
||||
|
||||
class DeleteUserForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class EditUserForm(BaseForm):
|
||||
localpart = wtforms.StringField(
|
||||
_l("Login name"),
|
||||
)
|
||||
|
||||
display_name = wtforms.StringField(
|
||||
_l("Display name"),
|
||||
)
|
||||
|
||||
role = wtforms.RadioField(
|
||||
_l("Access Level"),
|
||||
choices=[
|
||||
("prosody:restricted", _("Limited")),
|
||||
("prosody:normal", _l("Normal user")),
|
||||
("prosody:admin", _l("Administrator")),
|
||||
],
|
||||
)
|
||||
|
||||
action_save = wtforms.SubmitField(
|
||||
_l("Update user"),
|
||||
)
|
||||
|
||||
action_create_reset = wtforms.SubmitField(
|
||||
_l("Create password reset link"),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/user/<localpart>/", methods=["GET", "POST"])
|
||||
@client.require_admin_session()
|
||||
async def edit_user(localpart: str) -> typing.Union[quart.Response, str]:
|
||||
target_user_info = await client.get_user_by_localpart(localpart)
|
||||
|
||||
form = EditUserForm()
|
||||
if form.validate_on_submit():
|
||||
if form.action_create_reset.data:
|
||||
target_user_info = await client.get_user_by_localpart(localpart)
|
||||
reset_link = await client.create_password_reset_invite(
|
||||
localpart=localpart,
|
||||
ttl=86400,
|
||||
)
|
||||
await flash(
|
||||
_("Password reset link created"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(
|
||||
".user_password_reset_link",
|
||||
id_=reset_link.id_,
|
||||
))
|
||||
|
||||
await client.update_user(
|
||||
localpart,
|
||||
display_name=form.display_name.data,
|
||||
roles=[form.role.data],
|
||||
)
|
||||
|
||||
await flash(
|
||||
_("User information updated."),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_user", localpart=localpart))
|
||||
|
||||
elif request.method == "GET":
|
||||
form.localpart.data = target_user_info.localpart
|
||||
form.display_name.data = target_user_info.display_name
|
||||
if target_user_info.roles:
|
||||
form.role.data = target_user_info.roles[0]
|
||||
else:
|
||||
form.role.data = "prosody:normal"
|
||||
|
||||
return await render_template(
|
||||
"admin_edit_user.html",
|
||||
target_user=target_user_info,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
class DeleteUserForm(BaseForm):
|
||||
action_delete = wtforms.SubmitField(
|
||||
_l("Delete user permanently")
|
||||
)
|
||||
@@ -69,6 +153,10 @@ async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
|
||||
if form.validate_on_submit():
|
||||
if form.action_delete.data:
|
||||
await client.delete_user_by_localpart(localpart)
|
||||
await flash(
|
||||
_("User deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
return await render_template(
|
||||
@@ -94,37 +182,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()
|
||||
async def create_password_reset_link() -> typing.Union[str, quart.Response]:
|
||||
form = PasswordResetLinkPost()
|
||||
if not form.validate_on_submit():
|
||||
abort(400)
|
||||
|
||||
if form.action_create.data:
|
||||
localpart = form.action_create.data
|
||||
target_user_info = await client.get_user_by_localpart(localpart)
|
||||
reset_link = await client.create_password_reset_invite(
|
||||
localpart=localpart,
|
||||
ttl=86400,
|
||||
async def user_password_reset_link(
|
||||
id_: str,
|
||||
) -> typing.Union[str, quart.Response]:
|
||||
invite_info = await client.get_invite_by_id(
|
||||
id_,
|
||||
)
|
||||
if invite_info.jid is None:
|
||||
await flash(
|
||||
_("Password reset link not found"),
|
||||
"alert",
|
||||
)
|
||||
elif form.action_revoke.data:
|
||||
await client.delete_invite(form.action_revoke.data)
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
localpart = prosodyclient.split_jid(invite_info.jid)[0]
|
||||
|
||||
form = PasswordResetLinkPost()
|
||||
if form.validate_on_submit():
|
||||
if form.action_revoke.data:
|
||||
await client.delete_invite(id_)
|
||||
await flash(
|
||||
_("Password reset link deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_user", localpart=localpart))
|
||||
abort(400)
|
||||
|
||||
return await render_template(
|
||||
"admin_reset_user_password.html",
|
||||
target_user=target_user_info,
|
||||
reset_link=reset_link,
|
||||
localpart=localpart,
|
||||
reset_link=invite_info,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
class InvitesListForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class InvitesListForm(BaseForm):
|
||||
action_revoke = wtforms.StringField()
|
||||
|
||||
|
||||
class InvitePost(flask_wtf.FlaskForm): # type:ignore
|
||||
class InvitePost(BaseForm):
|
||||
circles = wtforms.SelectMultipleField(
|
||||
_l("Invite to circle"),
|
||||
# NOTE: This is for when/if we ever support multi-group invites.
|
||||
@@ -218,7 +316,7 @@ async def invitations() -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class InviteForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class InviteForm(BaseForm):
|
||||
action_revoke = wtforms.SubmitField(
|
||||
_l("Revoke")
|
||||
)
|
||||
@@ -243,6 +341,10 @@ async def create_invite() -> typing.Union[str, quart.Response]:
|
||||
group_ids=form.circles.data,
|
||||
ttl=form.lifetime.data,
|
||||
)
|
||||
await flash(
|
||||
_("Invitation created"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_invite", id_=invite.id_))
|
||||
return await render_template("admin_create_invite.html",
|
||||
invite_form=form)
|
||||
@@ -255,7 +357,11 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
|
||||
invite_info = await client.get_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
abort(404)
|
||||
await flash(
|
||||
_("No such invitation exists"),
|
||||
"alert",
|
||||
)
|
||||
return redirect(url_for(".invitations"))
|
||||
circles = await client.list_groups()
|
||||
circle_map = {
|
||||
circle.id_: circle
|
||||
@@ -266,6 +372,10 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
|
||||
if form.validate_on_submit():
|
||||
if form.action_revoke.data:
|
||||
await client.delete_invite(id_)
|
||||
await flash(
|
||||
_("Invitation revoked"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".invitations"))
|
||||
return redirect(url_for(".edit_invite", id_=id_))
|
||||
|
||||
@@ -278,7 +388,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(
|
||||
_l("Name"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
@@ -314,6 +424,10 @@ async def create_circle() -> typing.Union[str, quart.Response]:
|
||||
circle = await client.create_group(
|
||||
name=create_form.name.data,
|
||||
)
|
||||
await flash(
|
||||
_("Circle created"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_circle", id_=circle.id_))
|
||||
|
||||
return await render_template(
|
||||
@@ -322,7 +436,7 @@ async def create_circle() -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class EditCircleForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class EditCircleForm(BaseForm):
|
||||
name = wtforms.StringField(
|
||||
_l("Name"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
@@ -359,27 +473,27 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
await flash(
|
||||
_("No such circle exists"),
|
||||
"alert",
|
||||
)
|
||||
return redirect(url_for(".circles"))
|
||||
raise
|
||||
|
||||
circle_members = await asyncio.gather(*(
|
||||
client.get_user_by_localpart(
|
||||
localpart,
|
||||
session=session,
|
||||
)
|
||||
users = {
|
||||
user.localpart: user
|
||||
for user in await client.list_users()
|
||||
}
|
||||
circle_members = [
|
||||
(localpart, users.get(localpart))
|
||||
for localpart in sorted(circle.members)
|
||||
))
|
||||
|
||||
users = await client.list_users()
|
||||
]
|
||||
|
||||
form = EditCircleForm()
|
||||
form.user_to_add.choices = sorted(
|
||||
(
|
||||
(u.localpart, u.localpart)
|
||||
for u in users
|
||||
if u.localpart not in circle.members
|
||||
),
|
||||
key=lambda x: x[1]
|
||||
(localpart, localpart)
|
||||
for localpart in users.keys()
|
||||
if localpart not in circle.members
|
||||
)
|
||||
valid_users = [x[0] for x in form.user_to_add.choices]
|
||||
|
||||
@@ -396,25 +510,38 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
|
||||
id_,
|
||||
new_name=form.name.data,
|
||||
)
|
||||
await flash(
|
||||
_("Circle data updated"),
|
||||
"success",
|
||||
)
|
||||
elif form.action_delete.data:
|
||||
await client.delete_group(id_)
|
||||
await flash(
|
||||
_("Circle deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".circles"))
|
||||
elif form.action_add_user.data:
|
||||
if form.user_to_add.data in valid_users:
|
||||
print("is valid")
|
||||
await client.add_group_member(
|
||||
id_,
|
||||
form.user_to_add.data,
|
||||
)
|
||||
await flash(
|
||||
_("User added to circle"),
|
||||
"success",
|
||||
)
|
||||
elif form.action_remove_user.data:
|
||||
await client.remove_group_member(
|
||||
id_,
|
||||
form.action_remove_user.data,
|
||||
)
|
||||
await flash(
|
||||
_("User removed from circle"),
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for(".edit_circle", id_=id_))
|
||||
else:
|
||||
print(form.errors)
|
||||
|
||||
return await render_template(
|
||||
"admin_edit_circle.html",
|
||||
@@ -423,3 +550,153 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
|
||||
circle_members=circle_members,
|
||||
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
|
||||
|
||||
try:
|
||||
metrics["prosody_uploads"] = prosody_metrics["uploads"]
|
||||
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 itertools
|
||||
import math
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
@@ -10,6 +11,7 @@ from quart import (
|
||||
)
|
||||
|
||||
import flask_babel
|
||||
import flask_wtf
|
||||
from flask_babel import _
|
||||
|
||||
from . import prosodyclient
|
||||
@@ -21,11 +23,20 @@ client.default_login_redirect = "main.login"
|
||||
babel = flask_babel.Babel()
|
||||
|
||||
|
||||
BYTE_UNIT_SCALE_MAP = [
|
||||
"B",
|
||||
"kiB",
|
||||
"MiB",
|
||||
"GiB",
|
||||
"TiB",
|
||||
]
|
||||
|
||||
|
||||
@babel.localeselector # type:ignore
|
||||
def selected_locale() -> str:
|
||||
selected = request.accept_languages.best_match(
|
||||
current_app.config['LANGUAGES']
|
||||
)
|
||||
) or current_app.config['LANGUAGES'][0]
|
||||
return selected
|
||||
|
||||
|
||||
@@ -41,12 +52,30 @@ def circle_name(c: typing.Any) -> str:
|
||||
return c.name
|
||||
|
||||
|
||||
def format_bytes(n: float) -> str:
|
||||
try:
|
||||
scale = max(math.floor(math.log(n, 1024)), 0)
|
||||
except ValueError:
|
||||
scale = 0
|
||||
try:
|
||||
unit = BYTE_UNIT_SCALE_MAP[scale]
|
||||
factor = 1024**scale
|
||||
except IndexError:
|
||||
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:
|
||||
app.template_filter("repr")(repr)
|
||||
app.template_filter("format_datetime")(flask_babel.format_datetime)
|
||||
app.template_filter("format_date")(flask_babel.format_date)
|
||||
app.template_filter("format_time")(flask_babel.format_time)
|
||||
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
|
||||
app.template_filter("format_percent")(flask_babel.format_percent)
|
||||
app.template_filter("format_bytes")(format_bytes)
|
||||
app.template_filter("flatten")(flatten)
|
||||
app.template_filter("circle_name")(circle_name)
|
||||
|
||||
@@ -55,3 +84,14 @@ def generate_error_id() -> str:
|
||||
return base64.b32encode(secrets.token_bytes(8)).decode(
|
||||
"ascii"
|
||||
).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)
|
||||
|
||||
@@ -10,16 +10,16 @@ from quart import (
|
||||
current_app,
|
||||
render_template,
|
||||
redirect,
|
||||
request,
|
||||
url_for,
|
||||
session as http_session,
|
||||
)
|
||||
|
||||
import wtforms
|
||||
|
||||
import flask_wtf
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from flask_babel import lazy_gettext as _l, gettext
|
||||
|
||||
from .infra import client, selected_locale
|
||||
from .infra import client, selected_locale, BaseForm
|
||||
|
||||
|
||||
bp = Blueprint("invite", __name__)
|
||||
@@ -27,6 +27,11 @@ bp = Blueprint("invite", __name__)
|
||||
|
||||
INVITE_SESSION_JID = "invite-session-jid"
|
||||
|
||||
MAX_IMPORT_DATA_SIZE = 5*1024*1024 # 5MB
|
||||
SUPPORTED_IMPORT_TYPES = ["application/xml", "text/xml"]
|
||||
|
||||
EIMPORTTOOBIG = _l("The account data you tried to import is too large to"
|
||||
" upload. Please contact your Snikket operator.")
|
||||
|
||||
# https://play.google.com/store/apps/details?id=org.snikket.android&referrer={uri|urlescape}&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1
|
||||
|
||||
@@ -48,13 +53,20 @@ def context() -> typing.Mapping[str, typing.Any]:
|
||||
|
||||
|
||||
@bp.route("/<id_>")
|
||||
async def view(id_: str) -> str:
|
||||
async def view_old(id_: str) -> quart.Response:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
|
||||
|
||||
@bp.route("/<id_>/")
|
||||
async def view(id_: str) -> typing.Union[quart.Response,
|
||||
typing.Tuple[str, int],
|
||||
str]:
|
||||
try:
|
||||
invite = await client.get_public_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
# invite expired
|
||||
return await render_template("invite_invalid.html")
|
||||
return await render_template("invite_invalid.html"), 404
|
||||
raise
|
||||
|
||||
if invite.reset_localpart is not None:
|
||||
@@ -79,16 +91,23 @@ async def view(id_: str) -> str:
|
||||
)
|
||||
apple_store_url = current_app.config["APPLE_STORE_URL"]
|
||||
|
||||
return await render_template(
|
||||
body = await render_template(
|
||||
"invite_view.html",
|
||||
invite=invite,
|
||||
play_store_url=play_store_url,
|
||||
apple_store_url=apple_store_url,
|
||||
f_droid_url="market://details?id=org.snikket.android",
|
||||
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(
|
||||
_l("Username"),
|
||||
)
|
||||
@@ -102,7 +121,7 @@ class RegisterForm(flask_wtf.FlaskForm): # type:ignore
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"password",
|
||||
_l("The passwords must match")
|
||||
_l("The passwords must match.")
|
||||
)]
|
||||
)
|
||||
|
||||
@@ -134,15 +153,15 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 409:
|
||||
form.localpart.errors.append(
|
||||
_l("That username is already taken")
|
||||
_l("That username is already taken.")
|
||||
)
|
||||
elif exc.status == 403:
|
||||
form.localpart.errors.append(
|
||||
_l("Registration was declined for unknown reasons")
|
||||
_l("Registration was declined for unknown reasons.")
|
||||
)
|
||||
elif exc.status == 400:
|
||||
form.localpart.errors.append(
|
||||
_l("The username is not valid")
|
||||
_l("The username is not valid.")
|
||||
)
|
||||
elif exc.status == 404:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
@@ -150,6 +169,7 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
|
||||
raise
|
||||
else:
|
||||
http_session[INVITE_SESSION_JID] = jid
|
||||
await client.login(jid, form.password.data)
|
||||
return redirect(url_for(".success"))
|
||||
|
||||
return await render_template(
|
||||
@@ -159,7 +179,7 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class ResetForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class ResetForm(BaseForm):
|
||||
password = wtforms.PasswordField(
|
||||
_l("Password"),
|
||||
)
|
||||
@@ -169,7 +189,7 @@ class ResetForm(flask_wtf.FlaskForm): # type:ignore
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"password",
|
||||
_l("The passwords must match")
|
||||
_l("The passwords must match.")
|
||||
)]
|
||||
)
|
||||
|
||||
@@ -202,7 +222,7 @@ async def reset(id_: str) -> typing.Union[str, quart.Response]:
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 403:
|
||||
form.localpart.errors.append(
|
||||
_l("Registration was declined for unknown reasons")
|
||||
_l("Registration was declined for unknown reasons.")
|
||||
)
|
||||
elif exc.status == 404:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
@@ -219,11 +239,55 @@ async def reset(id_: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class DataImportForm(BaseForm):
|
||||
account_data_file = wtforms.FileField(
|
||||
_l("Account data file")
|
||||
)
|
||||
|
||||
action_import = wtforms.SubmitField(
|
||||
_l("Import data")
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/success", methods=["GET", "POST"])
|
||||
@client.require_session()
|
||||
async def success() -> str:
|
||||
form = DataImportForm()
|
||||
if form.validate_on_submit():
|
||||
ok = True
|
||||
file_info = (await request.files).get(form.account_data_file.name)
|
||||
if file_info is not None:
|
||||
mimetype = file_info.mimetype
|
||||
data = file_info.stream.read()
|
||||
if len(data) > MAX_IMPORT_DATA_SIZE:
|
||||
form.account_data_file.errors.append(EIMPORTTOOBIG)
|
||||
ok = False
|
||||
elif mimetype not in SUPPORTED_IMPORT_TYPES:
|
||||
form.account_data_file.errors.append(
|
||||
# not breaking the line here to avoid extract
|
||||
# translations failing (defensive)
|
||||
gettext("The account data you tried to import is in an unknown format. Please upload an XML file in XEP-0227 format (provided format: %(mimetype)s).", mimetype=mimetype), # noqa:E501
|
||||
)
|
||||
ok = False
|
||||
elif len(data) > 0:
|
||||
await client.import_account_data(data)
|
||||
|
||||
if ok:
|
||||
# Re-render success page, this time with no import option
|
||||
return await render_template(
|
||||
"invite_success.html",
|
||||
jid=http_session.get(INVITE_SESSION_JID, ""),
|
||||
migration_success=True,
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"invite_success.html",
|
||||
jid=http_session.get(INVITE_SESSION_JID, ""),
|
||||
migration_success=False,
|
||||
form=form,
|
||||
max_import_size=MAX_IMPORT_DATA_SIZE,
|
||||
import_too_big_warning_header=_l("Error"),
|
||||
import_too_big_warning=EIMPORTTOOBIG,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,23 +15,23 @@ from quart import (
|
||||
render_template,
|
||||
request,
|
||||
Response,
|
||||
flash,
|
||||
)
|
||||
|
||||
import babel
|
||||
import wtforms
|
||||
|
||||
import flask_wtf
|
||||
|
||||
from flask_babel import lazy_gettext as _l, _
|
||||
|
||||
from . import xmpputil, _version
|
||||
from .infra import client
|
||||
from .infra import client, BaseForm
|
||||
|
||||
|
||||
bp = quart.Blueprint("main", __name__)
|
||||
|
||||
|
||||
class LoginForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class LoginForm(BaseForm):
|
||||
address = wtforms.TextField(
|
||||
_l("Address"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
@@ -52,6 +52,9 @@ async def index() -> quart.Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
ERR_CREDENTIALS_INVALID = _l("Invalid username or password.")
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
async def login() -> typing.Union[str, quart.Response]:
|
||||
if client.has_session and (await client.test_session()):
|
||||
@@ -63,36 +66,61 @@ async def login() -> typing.Union[str, quart.Response]:
|
||||
localpart, domain, resource = xmpputil.split_jid(jid)
|
||||
if not localpart:
|
||||
localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"]
|
||||
jid = "{}@{}".format(localpart, domain)
|
||||
password = form.password.data
|
||||
try:
|
||||
await client.login(jid, password)
|
||||
except quart.exceptions.Unauthorized:
|
||||
form.password.errors.append(
|
||||
_("Invalid username or password.")
|
||||
)
|
||||
if domain != current_app.config["SNIKKET_DOMAIN"]:
|
||||
# (a) prosody throws a 400 at us and I prefer to catch that here
|
||||
# and (b) I don’t want to pass on this obviously not-for-here
|
||||
# password further than necessary.
|
||||
form.password.errors.append(ERR_CREDENTIALS_INVALID)
|
||||
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)
|
||||
|
||||
|
||||
@bp.route("/meta/about.html")
|
||||
async def about() -> str:
|
||||
version = None
|
||||
extra_versions = {}
|
||||
if current_app.debug:
|
||||
|
||||
if current_app.debug or client.is_admin_session:
|
||||
version = _version.version
|
||||
extra_versions["Quart"] = quart.__version__
|
||||
extra_versions["aiohttp"] = aiohttp.__version__
|
||||
extra_versions["babel"] = babel.__version__
|
||||
extra_versions["wtforms"] = wtforms.__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(
|
||||
"about.html",
|
||||
version=_version.version,
|
||||
version=version,
|
||||
extra_versions=extra_versions,
|
||||
)
|
||||
|
||||
@bp.route("/policies")
|
||||
async def policies() -> str:
|
||||
return await render_template(
|
||||
"policies.html",
|
||||
snikket_domain=current_app.config["SNIKKET_DOMAIN"],
|
||||
retention_days=current_app.config["RETENTION_DAYS"],
|
||||
operator_name=current_app.config["OPERATOR_NAME"],
|
||||
provider_name=current_app.config["PROVIDER_NAME"],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/meta/demo.html")
|
||||
async def demo() -> str:
|
||||
@@ -105,6 +133,7 @@ def repad(s: str) -> str:
|
||||
|
||||
@bp.route("/avatar/<from_>/<code>")
|
||||
async def avatar(from_: str, code: str) -> quart.Response:
|
||||
etag: typing.Optional[str]
|
||||
try:
|
||||
etag = request.headers["if-none-match"]
|
||||
except KeyError:
|
||||
@@ -144,3 +173,8 @@ async def avatar(from_: str, code: str) -> quart.Response:
|
||||
|
||||
response.set_data(data)
|
||||
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]
|
||||
email: typing.Optional[str]
|
||||
phone: typing.Optional[str]
|
||||
roles: typing.Optional[typing.List[str]]
|
||||
|
||||
@property
|
||||
def has_admin_role(self) -> bool:
|
||||
return bool(self.roles and "prosody:admin" in self.roles)
|
||||
|
||||
@property
|
||||
def has_restricted_role(self) -> bool:
|
||||
return bool(self.roles and "prosody:restricted" in self.roles)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
@@ -55,6 +64,7 @@ class AdminUserInfo:
|
||||
display_name=data.get("display_name") or None,
|
||||
email=data.get("email") or None,
|
||||
phone=data.get("phone") or None,
|
||||
roles=data.get("roles"),
|
||||
)
|
||||
|
||||
|
||||
@@ -286,6 +296,9 @@ class ProsodyClient:
|
||||
def _public_v1_endpoint(self, subpath: str) -> str:
|
||||
return "{}/register_api{}".format(self._endpoint_base, subpath)
|
||||
|
||||
def _xep227_endpoint(self, subpath: str) -> str:
|
||||
return "{}/xep227{}".format(self._endpoint_base, subpath)
|
||||
|
||||
async def _oauth2_bearer_token(self,
|
||||
session: aiohttp.ClientSession,
|
||||
jid: str,
|
||||
@@ -332,15 +345,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 with self._plain_session as session:
|
||||
token_info = await self._oauth2_bearer_token(
|
||||
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_CACHED_SCOPE] = " ".join(token_info.scopes)
|
||||
return True
|
||||
|
||||
@property
|
||||
@@ -445,6 +461,13 @@ class ProsodyClient:
|
||||
headers=final_headers,
|
||||
data=serialised) as resp:
|
||||
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)
|
||||
reply_payload = await resp.read()
|
||||
self.logger.debug(
|
||||
@@ -490,9 +513,32 @@ class ProsodyClient:
|
||||
"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
|
||||
|
||||
@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
|
||||
async def get_user_nickname(
|
||||
self,
|
||||
@@ -767,7 +813,7 @@ class ProsodyClient:
|
||||
# got there, replacing the current session token on the way.
|
||||
|
||||
async with self._plain_session as session:
|
||||
token = await self._oauth2_bearer_token(
|
||||
token_info = await self._oauth2_bearer_token(
|
||||
session,
|
||||
self.session_address,
|
||||
current_password,
|
||||
@@ -779,14 +825,14 @@ class ProsodyClient:
|
||||
new_password
|
||||
),
|
||||
headers={
|
||||
"Authorization": "Bearer {}".format(token),
|
||||
"Authorization": "Bearer {}".format(token_info.token),
|
||||
},
|
||||
sensitive=True,
|
||||
)
|
||||
# TODO: error handling
|
||||
# TODO: obtain a new token using the new password to allow the
|
||||
# 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(
|
||||
self,
|
||||
@@ -825,6 +871,29 @@ class ProsodyClient:
|
||||
self._raise_error_from_response(resp)
|
||||
return AdminUserInfo.from_api_response(await resp.json())
|
||||
|
||||
@autosession
|
||||
async def update_user(
|
||||
self,
|
||||
localpart: str,
|
||||
*,
|
||||
display_name: typing.Optional[str],
|
||||
roles: typing.Optional[typing.Collection[str]],
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
payload: typing.Dict[str, typing.Any] = {
|
||||
"username": localpart,
|
||||
}
|
||||
if display_name is not None:
|
||||
payload["display_name"] = display_name
|
||||
if roles is not None:
|
||||
payload["roles"] = list(roles)
|
||||
|
||||
async with session.put(
|
||||
self._admin_v1_endpoint("/users/{}".format(localpart)),
|
||||
json=payload,
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
|
||||
@autosession
|
||||
async def get_user_debug_info(
|
||||
self,
|
||||
@@ -1055,6 +1124,34 @@ class ProsodyClient:
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
|
||||
@autosession
|
||||
async def export_account_data(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession,
|
||||
) -> typing.Optional[str]:
|
||||
async with session.get(
|
||||
self._xep227_endpoint("/export?stores=roster,vcard,pep,pep_data"), # noqa:E501
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
if resp.status == 204:
|
||||
return None
|
||||
return await resp.text()
|
||||
|
||||
@autosession
|
||||
async def import_account_data(
|
||||
self,
|
||||
user_xml: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession,
|
||||
) -> bool:
|
||||
async with session.put(
|
||||
self._xep227_endpoint("/import?stores=roster,vcard,pep,pep_data"), # noqa:E501
|
||||
data=user_xml,
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
return True
|
||||
|
||||
@autosession
|
||||
async def revoke_token(
|
||||
self,
|
||||
@@ -1109,3 +1206,41 @@ class ProsodyClient:
|
||||
json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
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%];
|
||||
$small-screen-threshold: 40rem;
|
||||
$medium-screen-threshold: 60rem;
|
||||
$large-screen-threshold: 80rem;
|
||||
|
||||
@@ -33,13 +33,35 @@ body {
|
||||
|
||||
main {
|
||||
padding: $w-l1;
|
||||
margin-left: auto;
|
||||
max-width: 60rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#mwrap {
|
||||
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 */
|
||||
@@ -67,6 +89,10 @@ div#topbar {
|
||||
font-size: $_top-h-size;
|
||||
line-height: 1.5;
|
||||
|
||||
body.debug & {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-screen-threshold) {
|
||||
font-size: $_top-h-small-size;
|
||||
}
|
||||
@@ -134,22 +160,20 @@ body > footer {
|
||||
background-color: $gray-100;
|
||||
color: $gray-800;
|
||||
padding: 0 $w-l1;
|
||||
font-size: 92.21079115%;
|
||||
|
||||
ul {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
text-align: center;
|
||||
line-height: 1.6267076567643135;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: $w-l1 0;
|
||||
}
|
||||
|
||||
li:before {
|
||||
content: '•';
|
||||
padding-right: $w-s2;
|
||||
display: block;
|
||||
margin: $w-s1 0;
|
||||
}
|
||||
|
||||
a, a:visited, a:hover, a:active, a:focus {
|
||||
@@ -330,6 +354,15 @@ div.form.layout-expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radio-button-ext > label > p {
|
||||
margin-left: 1.75rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.radio-button-ext > label .icon {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
div.select-wrap {
|
||||
display: block;
|
||||
border-bottom: $w-s4 solid $primary-500;
|
||||
@@ -993,6 +1026,23 @@ div.profile-card {
|
||||
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 */
|
||||
|
||||
@@ -54,6 +54,8 @@ div.install-buttons {
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
list-style-type: none;
|
||||
margin: $w-l1 0;
|
||||
padding: 0;
|
||||
@@ -74,6 +76,10 @@ img.play {
|
||||
height: $w-l3;
|
||||
}
|
||||
|
||||
img.fdroid {
|
||||
height: $w-l3;
|
||||
}
|
||||
|
||||
.tabbox {
|
||||
display: flex;
|
||||
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,16 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<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" />
|
||||
</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/import_export/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-import_export" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M8.65 3.35L5.86 6.14c-.32.31-.1.85.35.85H8V13c0 .55.45 1 1 1s1-.45 1-1V6.99h1.79c.45 0 .67-.54.35-.85L9.35 3.35c-.19-.19-.51-.19-.7 0zM16 17.01V11c0-.55-.45-1-1-1s-1 .45-1 1v6.01h-1.79c-.45 0-.67.54-.35.85l2.79 2.78c.2.19.51.19.71 0l2.79-2.78c.32-.31.09-.85-.35-.85H16z" />
|
||||
</symbol>
|
||||
<!-- from: communication/qr_code/materialiconsround/24px.svg -->
|
||||
<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>
|
||||
@@ -47,6 +57,12 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<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" />
|
||||
</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 -->
|
||||
<symbol id="icon-add" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -72,6 +88,26 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<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" />
|
||||
</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: file/file_download/materialicons/24px.svg -->
|
||||
<symbol id="icon-download" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
|
||||
</symbol>
|
||||
<!-- from: file/file_upload/materialicons/24px.svg -->
|
||||
<symbol id="icon-upload" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z" />
|
||||
</symbol>
|
||||
<!-- from: file/folder/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-folder" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M10.59 4.59C10.21 4.21 9.7 4 9.17 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-1.41-1.41z" />
|
||||
</symbol>
|
||||
<!-- from: navigation/arrow_back/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-back" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -137,4 +173,9 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 18 KiB |
@@ -3,5 +3,7 @@
|
||||
{#- -#}
|
||||
<li>{% trans about_url=url_for('main.about') %}A <a href="{{ about_url }}">Snikket</a> service{% endtrans %}</li>
|
||||
{#- -#}
|
||||
<li>{% trans %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company.{% endtrans %}</li>
|
||||
{#- -#}
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "library.j2" import standard_button %}
|
||||
{% block head_lead %}
|
||||
<title>About Snikket</title>
|
||||
<title>{% trans %}About Snikket{% endtrans %}</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<main>
|
||||
@@ -14,10 +14,12 @@
|
||||
<p>{% trans agpl_url="https://www.gnu.org/licenses/agpl.html" %}The web portal software is licensed under the terms of the <a href="{{ agpl_url }}">Affero GNU General Public License, version 3.0 or later</a>. The full terms of the license can be reviewed using the aforementioned link.{% endtrans %}</p>
|
||||
<p>{% trans source_url="https://github.com/snikket-im/snikket-web-portal/" %}The source code of the web portal can be downloaded and viewed in <a href="{{ source_url }}">its GitHub repository</a>.{% endtrans %}</p>
|
||||
<p>{% trans source_url="https://material.io/resources/icons/", apache20_url="https://www.apache.org/licenses/LICENSE-2.0.txt" %}The icons used in the web portal are <a href="{{ source_url }}">Google’s Material Icons</a>, made available by Google under the terms of the <a href="{{ apache20_url }}">Apache 2.0 License</a>.{% endtrans %}</p>
|
||||
<h3>{% trans %}Trademarks{% endtrans %}</h3>
|
||||
<p>{% trans trademarks_url="https://snikket.org/about/trademarks/" %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company. For more information about the trademarks, visit the <a href="{{ trademarks_url }}">Snikket Trademarks information page</a>.{% endtrans %}
|
||||
<h3>{% trans %}Software Versions{% endtrans %}</h3>
|
||||
<pre>Snikket Server
|
||||
Domain: {{ config["SNIKKET_DOMAIN"] }}
|
||||
Snikket Web Portal ({{ version }})
|
||||
Snikket Web Portal{% if version %} ({{ version }}){% endif %}
|
||||
{%- if extra_versions -%}
|
||||
{% for name, version in extra_versions.items() %}
|
||||
{{ name }} ({{ version }}){% endfor %}
|
||||
|
||||
@@ -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>
|
||||
{% endcall %}
|
||||
<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 -%}
|
||||
</div>
|
||||
</form></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% 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 %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
@@ -40,8 +40,8 @@
|
||||
{%- endif -%}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("back", url_for(".circles"), class="secondary") -%}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
|
||||
{% trans %}Return to circle list{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
|
||||
</div>
|
||||
@@ -56,14 +56,21 @@
|
||||
{%- if circle_members -%}
|
||||
<div class="el-2 elevated"><table>
|
||||
<thead>
|
||||
<th>Login name</th>
|
||||
<th class="collapsible">Display name</th>
|
||||
<th>Actions</th>
|
||||
<th>{% trans %}Login name{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Display name{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for member in circle_members -%}
|
||||
{%- for localpart, member in circle_members -%}
|
||||
<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="nowrap">
|
||||
{%- 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>
|
||||
</dl>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%}
|
||||
{%- call standard_button("back", url_for(".invitations"), class="primary") %}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- call standard_button("back", url_for(".invitations"), class="tertiary") %}
|
||||
{% trans %}Return to invitation list{% endtrans %}
|
||||
{%- endcall %}
|
||||
{%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{#- -#}
|
||||
</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>
|
||||
{#- -#}
|
||||
<p>{% trans %}Go back to your user's web portal page.{% endtrans %}</p>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<col/>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}Valid until{% endtrans %}</th>
|
||||
<th>{% trans %}Expires{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Type{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Circle{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<form method="POST">
|
||||
{{- form.csrf_token -}}
|
||||
<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>
|
||||
<dd>
|
||||
<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") -%}
|
||||
{% trans %}Destroy link{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call standard_button("back", url_for(".users"), class="primary") -%}
|
||||
{%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
|
||||
105
snikket_web/templates/admin_system.html
Normal file
105
snikket_web/templates/admin_system.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% 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 %}Storage used by shared files{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.prosody_uploads | default(None) is not none -%}
|
||||
{{ metrics.prosody_uploads | 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" %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -15,17 +13,19 @@
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<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 class="nowrap">
|
||||
{%- call action_button("delete", url_for(".delete_user", localpart=user.localpart), class="secondary") -%}
|
||||
{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call action_button("bug_report", url_for(".debug_user", localpart=user.localpart), class="secondary") -%}
|
||||
{% trans user_name=user.localpart %}Show debug information for {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call custom_form_button("passwd", reset_form.action_create.name, user.localpart, class="secondary", slim=True) -%}
|
||||
{% trans user_name=user.localpart %}Create password reset link for {{ user_name }}{% endtrans %}
|
||||
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%}
|
||||
{% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</form>
|
||||
</td>
|
||||
@@ -33,5 +33,5 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</form>
|
||||
{%- include "admin_create_invite_form.html" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{{- 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 %}
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %}{% if body_class | default(False) %} class="{{ body_class }}"{% endif %}{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
|
||||
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %} class="{% if is_in_debug_mode %}debug{% endif %}{% if body_class | default(False) %} {{ body_class }}{% endif %}"{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="css/invite.css") }}">
|
||||
{% endblock %}
|
||||
{% 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" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.password.label }}
|
||||
{{ form.password }}
|
||||
{{ form.password(autocomplete="new-password") }}
|
||||
<p class="field-desc weak">{% trans %}Enter a secure password that you do not use anywhere else.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.password_confirm.label }}
|
||||
{{ form.password_confirm }}
|
||||
{{ form.password_confirm(autocomplete="new-password") }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("done", form.action_register, class="primary") -%}{%- endcall -%}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
{%- call render_errors(form) %}{% endcall -%}
|
||||
<div class="f-ebox">
|
||||
{{ form.password.label }}
|
||||
{{ form.password }}
|
||||
{{ form.password(autocomplete="new-password") }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.password_confirm.label }}
|
||||
{{ form.password_confirm }}
|
||||
{{ form.password_confirm(autocomplete="new-password") }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("passwd", form.action_reset, class="primary") -%}{%- endcall -%}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "invite.html" %}
|
||||
{% set body_id = "invite" %}
|
||||
{% from "library.j2" import form_button, clipboard_button %}
|
||||
{% from "library.j2" import form_button, clipboard_button, render_errors %}
|
||||
{% block head_lead %}
|
||||
<title>{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %}</title>
|
||||
{%- include "copy-snippet.html" -%}
|
||||
@@ -15,6 +15,47 @@
|
||||
{% trans %}Copy address{% endtrans %}
|
||||
{%- 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 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>
|
||||
|
||||
{% if migration_success %}
|
||||
<h2>{% trans %}Import successful{% endtrans %}</h2>
|
||||
<p>{% trans %}Congratulations! Your account data has been successfully imported.{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if form %}
|
||||
<h2>{% trans %}Moving to Snikket?{% endtrans %}</h2>
|
||||
<p>{% trans %}If you are moving from a different Snikket instance or another XMPP-compatible service, you may optionally import the data (contacts, profile information, etc.) from your previous account. When you have exported the data from your previous account, upload it using the form below.{% endtrans %}</p>
|
||||
|
||||
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
|
||||
<h3 class="form-title">{% trans %}Upload account data{% endtrans %}</h3>
|
||||
{{ form.csrf_token }}
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
<div class="f-ebox">
|
||||
{{ form.account_data_file.label }}
|
||||
{{ form.account_data_file(accept="application/xml",
|
||||
data_maxsize=max_import_size,
|
||||
data_warning_header=import_too_big_warning_header,
|
||||
data_maxsize_warning=import_too_big_warning) }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("upload", form.action_import, class="secondary") %}{% endcall -%}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.getElementById("{{ form.account_data_file.id }}").onchange = function() {
|
||||
var maxsize_s = this.dataset.maxsize;
|
||||
var maxsize = parseInt(maxsize_s);
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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/qrcode.min.js") }}"></script>
|
||||
<link rel="alternate" href="{{ invite.xmpp_uri }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="elevated box el-3">
|
||||
@@ -26,11 +27,12 @@
|
||||
<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>
|
||||
{%- 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 -%}
|
||||
<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>
|
||||
{%- 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 -%}
|
||||
</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>
|
||||
@@ -66,7 +68,7 @@
|
||||
{#- -#}
|
||||
<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>
|
||||
<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 id="qr-info-uri" class="tab-pane">
|
||||
@@ -83,10 +85,77 @@
|
||||
{%- endcall -%}
|
||||
</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">
|
||||
var catch_popover = function() {
|
||||
open_modal(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
var onload = function() {
|
||||
apply_qr_code(document.getElementById("qr-invite-page"));
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<div class="box warning">{#- -#}
|
||||
<header>{% trans %}Invalid input{% endtrans %}</header>
|
||||
{%- if error_list | length == 1 -%}
|
||||
<p>{{ error_list[0] }}.</p>
|
||||
<p>{{ error_list[0] }}</p>
|
||||
{%- else -%}
|
||||
<ul>
|
||||
{%- for error in error_list -%}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "library.j2" import box, form_button %}
|
||||
{% from "library.j2" import box, form_button, render_errors %}
|
||||
{% set body_id = "login" %}
|
||||
{% block head_lead %}
|
||||
<title>{{ _("Snikket Login") }}</title>
|
||||
@@ -9,16 +9,16 @@
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% 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>
|
||||
<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 }}
|
||||
{% if form.errors %}
|
||||
{% call box("alert", _("Login failed")) %}
|
||||
<p>{{ form.errors.values() | flatten | join(", ")}}</p>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
<div class="box alert" role="alert" style="display: none;" id="id-warning">
|
||||
<header>{% trans %}Incorrect address{% endtrans %}</header>
|
||||
<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>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.address.label(class="a11y-only") }}
|
||||
{{ form.address(placeholder=form.address.label.text) }}
|
||||
@@ -31,8 +31,22 @@
|
||||
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
|
||||
</div>
|
||||
</from>
|
||||
</div></main></div>
|
||||
<footer>
|
||||
<ul><li>{% trans about_url=url_for('.about') %}A <a href="{{ about_url }}">Snikket</a> service{% endtrans %}</li></ul>
|
||||
</footer>
|
||||
<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" -%}
|
||||
{% endblock %}
|
||||
|
||||
131
snikket_web/templates/policies.html
Normal file
131
snikket_web/templates/policies.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "library.j2" import standard_button %}
|
||||
{% block head_lead %}
|
||||
<title>{% trans %}Service Policies{% endtrans %}</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<main>
|
||||
<div class="box el-2">
|
||||
<h1>{% trans %}Service Policies{% endtrans %}</h1>
|
||||
<p>{% trans %}Here you will find the policies and legal notices that govern your use of the {{ snikket_domain }} communication service.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}If you do not agree to these policies, or are below the minimum age required to understand and consent to
|
||||
these terms, you must not use this service.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}This page contains the following policies:{% endtrans %}</p>
|
||||
|
||||
<ul>
|
||||
<li><strong><a href="#tos">{% trans %}Terms of Service{% endtrans %}</a></strong> {% trans %}The terms describe the acceptable use of our service, what we expect from you and what you can expect from us.{% endtrans %}</li>
|
||||
<li><strong><a href="#privacy">{% trans %}Privacy Policy{% endtrans %}</a></strong> {% trans %}The what, why and how we handle your personal data here on {{ snikket_domain }}.{% endtrans %}</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="tos">{% trans %}Terms of Service{% endtrans %}</h2>
|
||||
|
||||
<h3>{% trans %}1. Introduction{% endtrans %}</h3>
|
||||
|
||||
<p>{% trans %}Snikket is a privacy-oriented communication and messaging system that is designed to give you freedom of choice, privacy, and control over your communication and your data.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}The Snikket software allows anyone to set up their own online communication service, and connect it to other services in the network. There is a global network of Snikket services online run by independent operators. When you read about the "Service" in this document, it refers to this particular Snikket messaging and communication service available at the internet address <em>{{ snikket_domain }}</em>.{% endtrans %}</p>
|
||||
|
||||
{%- if operator_name and provider_name -%}
|
||||
<p>{% trans %}The Service is operated by <em>{{ operator_name }}</em> (us, the "Operator"), using facilities provided by <em>{{ provider_name }}</em>.{% endtrans %}</p>
|
||||
{%- elif operator_name -%}
|
||||
<p>{% trans %}The Service is operated by <em>{{ operator_name }}</em> (us, the "Operator").{% endtrans %}</p>
|
||||
{%- else -%}
|
||||
<p>{% trans %}The Service is operated privately by us (the "Operator").{% endtrans %}</p>
|
||||
{%- endif -%}
|
||||
|
||||
<p>{% trans %}The Service is using software developed by <em>Snikket Community Interest Company</em> and community contributors (collectively "the Developers"). The Developers are not associated with this Service, and they are not responsible for its reliability, security, maintenance, messages it sends, content it hosts, or the actions and activities of the Operator and users of the Service.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}"Snikket" and the parrot logo are trademarks of Snikket Community Interest Company.{% endtrans %}</p>
|
||||
|
||||
<h3>{% trans %}2. Your Data{% endtrans %}</h3>
|
||||
|
||||
<p>{% trans %}Certain data that you provide to us may be stored on your behalf to provide you with a secure, reliable and pleasant communication experience. You can request a copy of your data from the Operator at any time, and you can close your account if you no longer wish to use our service. For more information about the data we store, how long we store it for, and the purposes we store it for, please see the <a href="#privacy">Privacy Policy</a>.{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Legal basis for data processing.{% endtrans %}</strong> {% trans %}We process your data on the basis of Legitimate Interest. This means that we process your data only as necessary to deliver the Service, and in a manner that you understand and expect. This Legitimate Interest pertains to providing you with a secure communication service through which you may exchange messages, files and other data with other users and services. The processing of user data we undertake is necessary to provide this service.{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Third parties.{% endtrans %}</strong> {% trans %}Note well that, according to the nature of an open communication network, certain data you exchange with others (including messages and files) in the course of using the Service may be shared with, and possibly stored by, other users and their service operators on the basis of Legitimate Interest or any other applicable legal basis. We have no control over such copies of the data.{% endtrans %}</p>
|
||||
|
||||
<h3>{% trans %}3. Third-party services{% endtrans %}</h3>
|
||||
|
||||
<p><strong>{% trans %}Communication with third-party services.{% endtrans %}</strong> {% trans %}This Service is part of a global messaging network facilitated by standard technologies such as XMPP. In a similar fashion to the email and phone networks, this network allows people to communicate with each other even if they are using different services managed by different operators. When you communicate with users and groups that reside on other services, certain data may necessarily be exchanged with those services for the purposes of facilitating your communication. This includes your username, profile (e.g. display name and picture), messages and files that you send to the users and groups on those services.{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Third-party policies.{% endtrans %}</strong> {% trans %}Our Service may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Right to be forgotten.{% endtrans %}</strong> {% trans %}Your copy of your data on {{ snikket_domain }} will be erased upon your request to us. You may also make such requests to the operators of third-party services you have communicated with, however these services are not under our control and we cannot guarantee they will forget your data. Such services can be located anywhere in the world, and are subject to local laws and regulations.{% endtrans %}</p>
|
||||
|
||||
<h3>{% trans %}4. Acceptable use{% endtrans %}</h3>
|
||||
|
||||
<p><strong>{% trans %}Legal and acceptable purposes.{% endtrans %}</strong> {% trans %}You agree to access and use the Service only for legal, authorized, and acceptable purposes. You will not use (or assist others in using) our Service in ways that: (a) violate or infringe the rights of the Operator, users, or others, including privacy, publicity, intellectual property, or other proprietary rights; (b) involve sending illegal or impermissible communications such as unsolicited bulk communications (e.g. spam).{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Encryption.{% endtrans %}</strong> {% trans %}In the event that you wish to use encrypted communications within the Services, it is your responsibility to ensure this is permitted in under the laws and regulations applicable to you based on where and how you use the Services.{% endtrans %}</p>
|
||||
|
||||
<h3>{% trans %}5. Availability of Services{% endtrans %}</h3>
|
||||
|
||||
<p><strong>{% trans %}General availability{% endtrans %}</strong> {% trans %}Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time.{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Termination of access.{% endtrans %}</strong> {% trans %}We may remove your access to the Service at any time, at our sole discretion.{% endtrans %}</p>
|
||||
|
||||
<p><strong>{% trans %}Emergency services inaccessibility.{% endtrans %}</strong> {% trans %}The Communication Service is not to be used to make calls to any emergency services. Therefore you must arrange for other communications such as though a mobile phone or otherwise to enable you to contact any emergency services. We disclaim any liability relating to the inability to use the Communication Services in this way.{% endtrans %}</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h2 id="privacy">{% trans %}Privacy Policy{% endtrans %}</h2>
|
||||
|
||||
<h3 id="what-information-does-a-snikket-service-collect">{% trans %}What information does a Snikket service collect?{% endtrans %}</h3>
|
||||
<h4 id="basic-account-information">{% trans %}Basic account information{% endtrans %}</h4>
|
||||
<p>{% trans %}When you create an account on this service, your username will be stored, along with a hashed version of your password.{% endtrans %}</p>
|
||||
<p>{% trans %}You may additionally provide a profile picture (avatar) and display name. These will be shared with other users on the network, so they are able to identify you. You can control visibility of this information in the profile section of the {{ snikket_domain }} website.{% endtrans %}</p>
|
||||
<p>{% trans %}Contacts that you add within the app will be stored in your contact list on the {{ snikket_domain }} server. This is so that the server can identify who you have permitted to view your online status, profile and other information, and to synchronize your Snikket contacts if you have multiple apps or devices.{% endtrans %}</p>
|
||||
|
||||
<h4 id="messages">{% trans %}Messages{% endtrans %}</h4>
|
||||
|
||||
<p>{% trans %}When you send or receive a message on Snikket, we store this temporarily in your personal “message archive” on {{ snikket_domain }}. The purpose of your message archive is to enable an app you use with your account to “catch up” on recent conversations. This allows Snikket to:{% endtrans %}</p>
|
||||
<ul>
|
||||
<li>{% trans %}ensure delivery of messages even if you are temporarily offline or
|
||||
experiencing connectivity issues, and{% endtrans %}</li>
|
||||
<li>{% trans %}allow synchronization of messages across multiple devices and apps
|
||||
that you may use.{% endtrans %}</li>
|
||||
</ul>
|
||||
|
||||
<p>{% trans %}The data stored for each entry in the message archive is:{% endtrans %}</p>
|
||||
<ul>
|
||||
<li>{% trans %}A unique identifier for the message{% endtrans %}</li>
|
||||
<li>{% trans %}The time and date that the message was sent/received{% endtrans %}</li>
|
||||
<li>{% trans %}The sender and recipient of the message{% endtrans %}</li>
|
||||
<li>{% trans %}The message contents (encrypted according to your app’s settings){% endtrans %}</li>
|
||||
</ul>
|
||||
|
||||
<p>{% trans %}Entries in the message archive are stored for a minimum of {{ retention_days }} days. The server will routinely erase all entries after they have been in the archive for this amount of time.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}We encourage the use of encryption of your message contents, as is the default within the Snikket app.{% endtrans %}</p>
|
||||
|
||||
<h4 id="uploaded-files">{% trans %}Uploaded files{% endtrans %}</h4>
|
||||
<p>{% trans %}You may also use the server to upload files (including images and videos) within your conversations. These files will remain on the server for a minimum of {{ retention_days }} days. This allows your contacts time to retrieve the file, even if they are offline. Similarly to message archives, the server will routinely erase files beyond this age.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}Uploaded files are assigned a long random identifier, included in the link to the file. This ensures your files can only be viewed by people you share the link with.{% endtrans %}</p>
|
||||
|
||||
<p>{% trans %}The server will store the following information for every shared file:{% endtrans %}</p>
|
||||
<ul>
|
||||
<li>{% trans %}A unique identifier{% endtrans %}</li>
|
||||
<li>{% trans %}The time and date that the file was uploaded{% endtrans %}</li>
|
||||
<li>{% trans %}The file name{% endtrans %}</li>
|
||||
<li>{% trans %}The file size{% endtrans %}</li>
|
||||
<li>{% trans %}The file type (as reported by the app){% endtrans %}</li>
|
||||
<li>{% trans %}The file contents (encrypted according to your app’s settings){% endtrans %}</li>
|
||||
</ul>
|
||||
|
||||
<p>{% trans %}The Snikket app will automatically encrypt file contents when sharing a file within an encrypted conversation.{% endtrans %}</p>
|
||||
<p>{% trans %}Once you share a file with a contact, understand that the contact may store a copy of the file on their device that is beyond our control and may remain even after the file is removed from {{ snikket_domain }}.{% endtrans %}</p>
|
||||
|
||||
<h4 id="access-and-network-information">{% trans %}Access and network information{% endtrans %}</h4>
|
||||
<p>{% trans %}The Snikket server may record the time and general location from which you connect to your account or perform certain security-related actions, such as changing your password.{% endtrans %}</p>
|
||||
<p>{% trans %}This is to help identify unauthorized access to your account, and detect when your account becomes inactive for administrative purposes (for example, so that it may be erased when no longer needed).{% endtrans %}</p>
|
||||
|
||||
<h4 id="cookies">{% trans %}Cookies{% endtrans %}</h4>
|
||||
<p>{% trans %}When you access your account through the {{ snikket_domain }} website, one or more small pieces of data known as “cookies” may be stored in your web browser. These essential cookies allow us to securely identify your browser as you move between different pages on {{ snikket_domain }}, and therefore protect your account from unauthorized access. The cookies are not shared with third-parties or used for tracking, advertising or any such purposes.{% endtrans %}</p>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -7,6 +7,25 @@
|
||||
<div class="filler"></div>
|
||||
{% block topbar_right %}{% endblock %}
|
||||
</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" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<div>
|
||||
<div>{% call standard_button("edit", url_for(".profile"), class="primary") %}{% trans %}Edit profile{% endtrans %}{% endcall %}</div>
|
||||
<div>{% call standard_button("passwd", url_for(".change_pw"), class="secondary") %}{% trans %}Change password{% endtrans %}{% endcall %}</div>
|
||||
<div>{% call standard_button("folder", url_for(".manage_data"), class="secondary") %}{% trans %}Manage your data{% endtrans %}{% endcall %}</div>
|
||||
</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, form_button %}
|
||||
{% block head_lead %}
|
||||
<title>Snikket Web Portal</title>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form layout-expanded"><form method="POST">
|
||||
<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>
|
||||
{{ form.csrf_token }}
|
||||
<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 %}
|
||||
{%- endcall -%}
|
||||
{%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%}
|
||||
|
||||
22
snikket_web/templates/user_manage_data.html
Normal file
22
snikket_web/templates/user_manage_data.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Manage your data{% endtrans %}</h1>
|
||||
<nav class="welcome">
|
||||
<ul>
|
||||
<li>
|
||||
<h2>{% trans %}Export account{% endtrans %}</h2>
|
||||
<p>{% trans %}Download your account data as a file for backup purposes or to move your account to another service.{% endtrans %}</p>
|
||||
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
|
||||
<div class="f-bbox">
|
||||
<form method="POST">
|
||||
{{ form.csrf_token }}
|
||||
{%- call form_button("download", form.action_export, class="primary") %}{% endcall -%}
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,5 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, custom_form_button, render_errors %}
|
||||
{% block head_lead %}
|
||||
<title>Snikket Web Portal</title>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form layout-expanded"><form method="POST">
|
||||
<h1 class="form-title">{% trans %}Change your password{% endtrans %}</h1>
|
||||
@@ -12,22 +9,22 @@
|
||||
{%- endcall -%}
|
||||
<div class="f-ebox">
|
||||
{{ form.current_password.label(class="required") }}
|
||||
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else "")) }}
|
||||
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else ""), autocomplete="current-password") }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.new_password.label(class="required") }}
|
||||
{{ form.new_password }}
|
||||
{{ form.new_password(autocomplete="new-password") }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.new_password_confirm.label(class="required") }}
|
||||
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else "")) }}
|
||||
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else ""), autocomplete="new-password") }}
|
||||
</div>
|
||||
<div class="box warning">
|
||||
<header>{% trans %}Warning{% endtrans %}</header>
|
||||
<p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p>
|
||||
</div>
|
||||
<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") -%}
|
||||
{% trans %}Change password{% endtrans %}
|
||||
{%- endcall -%}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, form_button, avatar with context %}
|
||||
{% block head_lead %}
|
||||
<title>Snikket Web Portal</title>
|
||||
{% endblock %}
|
||||
{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Update your profile{% endtrans %}</h1>
|
||||
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
|
||||
<h2 class="form-title">{% trans %}Profile{% endtrans %}</h2>
|
||||
{{ form.csrf_token }}
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
<div class="f-ebox">
|
||||
{{ form.nickname.label }}
|
||||
{{ form.nickname(placeholder=user_info.username) }}
|
||||
@@ -16,7 +14,10 @@
|
||||
{{ form.avatar.label }}
|
||||
<div class="avatar-wrap">
|
||||
{%- 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>
|
||||
<h3 class="form-title">{% trans %}Visibility{% endtrans %}</h3>
|
||||
@@ -28,8 +29,27 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
<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 -%}
|
||||
</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>
|
||||
{% 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.
1648
snikket_web/translations/da/LC_MESSAGES/messages.po
Normal file
1648
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.
1612
snikket_web/translations/es_MX/LC_MESSAGES/messages.po
Normal file
1612
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.
1651
snikket_web/translations/sv/LC_MESSAGES/messages.po
Normal file
1651
snikket_web/translations/sv/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,30 @@
|
||||
import asyncio
|
||||
import typing
|
||||
import urllib
|
||||
|
||||
import quart.flask_patch
|
||||
from quart import Blueprint, render_template, request, redirect, url_for
|
||||
from quart import (
|
||||
Blueprint,
|
||||
Response,
|
||||
render_template,
|
||||
request,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
current_app,
|
||||
)
|
||||
import quart.exceptions
|
||||
|
||||
import wtforms
|
||||
|
||||
import flask_wtf
|
||||
|
||||
from flask_babel import lazy_gettext as _l, _
|
||||
|
||||
from .infra import client
|
||||
from .infra import client, BaseForm
|
||||
|
||||
bp = Blueprint('user', __name__)
|
||||
|
||||
|
||||
class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class ChangePasswordForm(BaseForm):
|
||||
current_password = wtforms.PasswordField(
|
||||
_l("Current password"),
|
||||
validators=[wtforms.validators.InputRequired()]
|
||||
@@ -32,12 +40,12 @@ class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"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(
|
||||
_l("Sign out"),
|
||||
)
|
||||
@@ -50,7 +58,7 @@ _ACCESS_MODEL_CHOICES = [
|
||||
]
|
||||
|
||||
|
||||
class ProfileForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class ProfileForm(BaseForm):
|
||||
nickname = wtforms.TextField(
|
||||
_l("Display name"),
|
||||
)
|
||||
@@ -69,6 +77,16 @@ class ProfileForm(flask_wtf.FlaskForm): # type:ignore
|
||||
)
|
||||
|
||||
|
||||
class ImportAccountDataForm(BaseForm):
|
||||
account_data_file = wtforms.FileField(
|
||||
_l("Account data")
|
||||
)
|
||||
|
||||
action_upload = wtforms.SubmitField(
|
||||
_l("Upload"),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@client.require_session()
|
||||
async def index() -> str:
|
||||
@@ -90,17 +108,29 @@ async def change_pw() -> typing.Union[str, quart.Response]:
|
||||
quart.exceptions.Forbidden):
|
||||
# server refused current password, set an appropriate error
|
||||
form.current_password.errors.append(
|
||||
_("Incorrect password"),
|
||||
_("Incorrect password."),
|
||||
)
|
||||
else:
|
||||
await flash(
|
||||
_("Password changed"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("user.change_pw"))
|
||||
|
||||
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"])
|
||||
@client.require_session()
|
||||
async def profile() -> typing.Union[str, quart.Response]:
|
||||
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
|
||||
|
||||
form = ProfileForm()
|
||||
if request.method != "POST":
|
||||
user_info = await client.get_user_info()
|
||||
@@ -114,26 +144,79 @@ async def profile() -> typing.Union[str, quart.Response]:
|
||||
if form.validate_on_submit():
|
||||
user_info = await client.get_user_info()
|
||||
|
||||
ok = True
|
||||
file_info = (await request.files).get(form.avatar.name)
|
||||
if file_info is not None:
|
||||
mimetype = file_info.mimetype
|
||||
data = file_info.stream.read()
|
||||
if len(data) > 0:
|
||||
if len(data) > max_avatar_size:
|
||||
form.avatar.errors.append(EAVATARTOOBIG)
|
||||
ok = False
|
||||
elif len(data) > 0:
|
||||
await client.set_user_avatar(data, mimetype)
|
||||
|
||||
if user_info.get("nickname") != form.nickname.data:
|
||||
await client.set_user_nickname(form.nickname.data)
|
||||
if ok:
|
||||
if user_info.get("nickname") != form.nickname.data:
|
||||
await client.set_user_nickname(form.nickname.data)
|
||||
|
||||
access_model = form.profile_access_model.data
|
||||
await asyncio.gather(
|
||||
client.set_avatar_access_model(access_model),
|
||||
client.set_vcard_access_model(access_model),
|
||||
client.set_nickname_access_model(access_model),
|
||||
access_model = form.profile_access_model.data
|
||||
await asyncio.gather(
|
||||
client.set_avatar_access_model(access_model),
|
||||
client.set_vcard_access_model(access_model),
|
||||
client.set_nickname_access_model(access_model),
|
||||
)
|
||||
|
||||
await flash(
|
||||
_("Profile updated"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".profile"))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class DataExportForm(BaseForm):
|
||||
action_export = wtforms.SubmitField(
|
||||
_l("Export")
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/manage_data", methods=["GET", "POST"])
|
||||
@client.require_session()
|
||||
async def manage_data() -> typing.Union[str, quart.Response]:
|
||||
form = DataExportForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
user_info = await client.get_user_info()
|
||||
# The UTF-8 version of the filename needs to be percent-encoded
|
||||
encoded_address = urllib.parse.quote(
|
||||
user_info["address"].encode(encoding='utf-8', errors='strict')
|
||||
)
|
||||
|
||||
return redirect(url_for(".profile"))
|
||||
|
||||
return await render_template("user_profile.html", form=form)
|
||||
account_data = await client.export_account_data()
|
||||
if account_data is None:
|
||||
await flash(
|
||||
_("You currently have no account data to export."),
|
||||
"alert"
|
||||
)
|
||||
else:
|
||||
return Response(account_data,
|
||||
mimetype="application/xml",
|
||||
headers={
|
||||
# We provide the UTF-8 filename, but the ASCII
|
||||
# one will be used as a fallback for legacy
|
||||
# browsers (RFC 5987)
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="account-data.xml"; '
|
||||
'filename*="UTF-8\'\'account-data-{}.xml"'
|
||||
).format(encoded_address)
|
||||
})
|
||||
return await render_template("user_manage_data.html",
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/logout", methods=["GET", "POST"])
|
||||
@@ -142,6 +225,12 @@ async def logout() -> typing.Union[quart.Response, str]:
|
||||
form = LogoutForm()
|
||||
if form.validate_on_submit():
|
||||
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 await render_template("user_logout.html", form=form)
|
||||
|
||||
@@ -207,7 +207,7 @@ def make_avatar_metadata_set_request(
|
||||
item,
|
||||
"metadata", xmlns=NS_USER_AVATAR_METADATA)
|
||||
|
||||
attr: typing.MutableMapping[str, str] = {
|
||||
attr: typing.Dict[str, str] = {
|
||||
"id": id_,
|
||||
"bytes": str(size),
|
||||
"type": mimetype,
|
||||
@@ -217,7 +217,12 @@ def make_avatar_metadata_set_request(
|
||||
if height is not None:
|
||||
attr["height"] = str(height)
|
||||
|
||||
ET.SubElement(metadata_wrap, "info", xmlns=NS_USER_AVATAR_METADATA, **attr)
|
||||
ET.SubElement(
|
||||
metadata_wrap,
|
||||
"info",
|
||||
xmlns=NS_USER_AVATAR_METADATA,
|
||||
**attr, # type: ignore
|
||||
)
|
||||
return req
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,20 @@ action/delete:delete
|
||||
action/logout:logout
|
||||
action/login:login
|
||||
action/exit_to_app:exit_to_app
|
||||
action/lock:lock
|
||||
communication/import_export:import_export
|
||||
communication/qr_code:qrcode
|
||||
communication/vpn_key:passwd
|
||||
communication/rss_feed:broadcast
|
||||
content/add_circle_outline:add
|
||||
content/add_link:create_link
|
||||
content/remove_circle_outline:remove
|
||||
content/content_copy:copy
|
||||
content/link_off:remove_link
|
||||
content/send:send
|
||||
file/file_download:download
|
||||
file/file_upload:upload
|
||||
file/folder:folder
|
||||
navigation/arrow_back:back
|
||||
navigation/arrow_forward:forward
|
||||
navigation/cancel:cancel
|
||||
@@ -25,3 +32,4 @@ navigation/close:close
|
||||
image/edit:edit
|
||||
action/admin_panel_settings:admin
|
||||
content/link:link
|
||||
content/insights:insights
|
||||
|
||||
Reference in New Issue
Block a user