Compare commits

..

3 Commits

Author SHA1 Message Date
Jonas Schäfer
1da45395c2 Merge branch 'hotfix/f-droid-button' into alpha 2021-06-20 14:16:11 +02:00
Jonas Schäfer
7dde3a1128 Bump version number manually for hopefully the last time
(As the master branch now uses automatic version detection.)
2021-06-20 14:12:55 +02:00
Matthew Wild
934976c114 README: Add short intro 2021-05-30 18:29:33 +01:00
67 changed files with 3756 additions and 12664 deletions

View File

@@ -27,7 +27,6 @@ jobs:
set -euo pipefail set -euo pipefail
pip install mypy pip install mypy
pip install -r requirements.txt pip install -r requirements.txt
pip install -r build-requirements.txt
- name: Typecheck - name: Typecheck
run: | run: |
python -m mypy --config mypy.ini -p snikket_web python -m mypy --config mypy.ini -p snikket_web
@@ -45,34 +44,11 @@ jobs:
- name: Install - name: Install
run: | run: |
set -euo pipefail set -euo pipefail
pip install flake8 flake8-print pip install flake8
- name: Linting - name: Linting
run: | run: |
python -m flake8 snikket_web python -m flake8 snikket_web
translation-check:
runs-on: ubuntu-latest
name: 'lint: i18n'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install
run: |
set -euo pipefail
pip install flask-babel
- name: Linting
run: |
sed -ri '/^"POT-Creation-Date: /d' snikket_web/translations/messages.pot
git add snikket_web/translations/messages.pot
make extract_translations
sed -ri '/^"POT-Creation-Date: /d' snikket_web/translations/messages.pot
git diff --exit-code --color -- snikket_web/translations/messages.pot
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,53 +1,41 @@
FROM debian:bookworm-slim AS build FROM debian:buster-slim
RUN set -eu; \ ARG BUILD_SERIES=dev
export DEBIAN_FRONTEND=noninteractive ; \ ARG BUILD_ID=0
apt-get update ; \
apt-get install -y --no-install-recommends \
python3 python3-mypy python3-dotenv python3-toml python3-babel python3-distutils \
sassc make;
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 Makefile /opt/snikket-web-portal/Makefile
COPY snikket_web/ /opt/snikket-web-portal/snikket_web COPY snikket_web/ /opt/snikket-web-portal/snikket_web
COPY babel.cfg /opt/snikket-web-portal/babel.cfg COPY babel.cfg /opt/snikket-web-portal/babel.cfg
WORKDIR /opt/snikket-web-portal WORKDIR /opt/snikket-web-portal
RUN make
FROM debian:bookworm-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/
WORKDIR /opt/snikket-web-portal
RUN set -eu; \ RUN set -eu; \
export DEBIAN_FRONTEND=noninteractive ; \ export DEBIAN_FRONTEND=noninteractive ; \
apt-get update ; \ apt-get update ; \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
netcat-traditional python3 python3-setuptools python3-pip \ python3 python3-pip python3-setuptools python3-wheel \
python3-aiohttp python3-email-validator python3-flask-babel \ libpython3-dev \
python3-flaskext.wtf python3-hsluv python3-hypercorn \ make build-essential \
python3-quart python3-typing-extensions python3-wtforms ; \ netcat \
pip3 install --break-system-packages environ-config ; \ ; \
apt-get remove -y --purge python3-pip python3-setuptools; \ pip3 install -r requirements.txt; \
apt-get clean ; rm -rf /var/lib/apt/lists; \ pip3 install -r build-requirements.txt; \
rm -rf /root/.cache; make; \
pip3 uninstall -yr build-requirements.txt; \
apt-get remove -y build-essential make libpython3-dev; \
apt-get autoremove -y; \
pip3 install hypercorn; \
rm -rf /root/.cache; \
apt-get clean ; rm -rf /var/lib/apt/lists
COPY docker/env.py /etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
HEALTHCHECK CMD nc -zv ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE:-127.0.0.1} ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT:-5765} 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 ADD docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

View File

@@ -6,7 +6,7 @@ translation_basepath = snikket_web/translations
pot_file = $(translation_basepath)/messages.pot pot_file = $(translation_basepath)/messages.pot
PYTHON3 ?= python3 PYTHON3 ?= python3
SCSSC ?= sassc --load-path snikket_web/scss/ SCSSC ?= $(PYTHON3) -m scss --load-path snikket_web/scss/
all: build_css compile_translations all: build_css compile_translations
@@ -14,7 +14,7 @@ build_css: $(generated_css_files)
$(generated_css_files): snikket_web/static/css/%.css: snikket_web/scss/%.scss $(scss_files) $(scss_includes) $(generated_css_files): snikket_web/static/css/%.css: snikket_web/scss/%.scss $(scss_files) $(scss_includes)
mkdir -p snikket_web/static/css/ mkdir -p snikket_web/static/css/
$(SCSSC) "$<" "$@" $(SCSSC) -o "$@" "$<"
clean: clean:
rm -f $(generated_css_files) rm -f $(generated_css_files)

View File

@@ -1,5 +1,11 @@
# Snikket Web Portal # Snikket Web Portal
This is the web component of a [Snikket service](https://snikket.org/service/)
that allows users to manage accounts, and administrators to manage the
service. For general setup, see the [Snikket install
guide](https://snikket.org/service/quickstart/). For developers working on
Snikket, see the development quickstart below.
![Screenshot of the app](docs/readme-screenshot.png) ![Screenshot of the app](docs/readme-screenshot.png)
## Development quickstart ## Development quickstart

View File

@@ -1,3 +1,4 @@
[python: snikket_web/**.py] [python: snikket_web/**.py]
[jinja2: snikket_web/templates/**.html] [jinja2: snikket_web/templates/**.html]
[jinja2: snikket_web/templates/**.j2] [jinja2: snikket_web/templates/**.j2]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

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

View File

@@ -1,16 +1,8 @@
#!/bin/sh #!/bin/sh
export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN" export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN"
if [ -n "${SNIKKET_SITE_NAME:-}" ]; then
export SNIKKET_WEB_SITE_NAME="$SNIKKET_SITE_NAME"
fi
export SNIKKET_WEB_TOS_URI="${SNIKKET_TOS_URI}"
export SNIKKET_WEB_PRIVACY_URI="${SNIKKET_PRIVACY_URI}"
export SNIKKET_WEB_ABUSE_EMAIL="${SNIKKET_ABUSE_EMAIL}"
export SNIKKET_WEB_SECURITY_EMAIL="${SNIKKET_SECURITY_EMAIL}"
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE-127.0.0.1}" 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}" 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}" --access-logfile=- --log-file=- 'snikket_web:create_app()' exec hypercorn -b "${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE}:${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT}" 'snikket_web:create_app()'

View File

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

View File

@@ -18,12 +18,10 @@ from quart import (
jsonify, jsonify,
) )
import werkzeug.exceptions
import environ import environ
from . import colour, infra from . import colour, infra
from ._version import version # noqa:F401 from ._version import version, version_info # noqa:F401
async def proc() -> typing.Dict[str, typing.Any]: async def proc() -> typing.Dict[str, typing.Any]:
@@ -42,7 +40,7 @@ async def proc() -> typing.Dict[str, typing.Any]:
try: try:
user_info = await infra.client.get_user_info() user_info = await infra.client.get_user_info()
except (aiohttp.ClientError, werkzeug.exceptions.HTTPException): except (aiohttp.ClientError, quart.exceptions.HTTPException):
user_info = {} user_info = {}
return { return {
@@ -107,16 +105,16 @@ async def backend_error_handler(exc: Exception) -> quart.Response:
async def generic_http_error( async def generic_http_error(
exc: werkzeug.exceptions.HTTPException, exc: quart.exceptions.HTTPException,
) -> quart.Response: ) -> quart.Response:
return quart.Response( return quart.Response(
await render_template( await render_template(
"generic_http_error.html", "generic_http_error.html",
status=exc.code, status=exc.status_code,
description=exc.description, description=exc.description,
name=exc.name, name=exc.name,
), ),
status=exc.code, status=exc.status_code,
) )
@@ -147,33 +145,21 @@ class AppConfig:
site_name = environ.var("") site_name = environ.var("")
avatar_cache_ttl = environ.var(1800, converter=int) avatar_cache_ttl = environ.var(1800, converter=int)
languages = environ.var([ languages = environ.var([
# Keep `en` as the first language, because it is used as a fallback
# if the language negotiation cannot find another match. It is more
# likely that users are able to read english (or find a suitable
# online translator) than, for instance, danish.
"en",
"da", "da",
"de", "de",
"en",
"fr", "fr",
"id", "id",
"it", "it",
"pl", "pl",
"sv", "sv",
"zh_Hans_CN",
], converter=autosplit) ], converter=autosplit)
apple_store_url = environ.var( apple_store_url = environ.var("")
"https://apps.apple.com/us/app/snikket/id1545164189",
)
# Default limit of 1 MiB is what was discovered to be the effective limit # Default limit of 1 MiB is what was discovered to be the effective limit
# in #67, hence we set that here for now. # in #67, hence we set that here for now.
# Future versions may change this default, and the standard deployment # Future versions may change this default, and the standard deployment
# tools may also very well override it. # tools may also very well override it.
max_avatar_size = environ.var(1024*1024, converter=int) max_avatar_size = environ.var(1024*1024, converter=int)
show_metrics = environ.bool_var(True)
tos_uri = environ.var("")
privacy_uri = environ.var("")
abuse_email = environ.var("")
security_email = environ.var("")
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1))) _UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
@@ -186,7 +172,7 @@ def create_app() -> quart.Quart:
pass pass
else: else:
import runpy import runpy
init_vars = runpy.run_path(env_init) init_vars = runpy.run_path(env_init) # type:ignore
for name, value in init_vars.items(): for name, value in init_vars.items():
if not name: if not name:
continue continue
@@ -205,28 +191,23 @@ def create_app() -> quart.Quart:
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
app.config["APPLE_STORE_URL"] = config.apple_store_url app.config["APPLE_STORE_URL"] = config.apple_store_url
app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size
app.config["SHOW_METRICS"] = config.show_metrics
app.config["TOS_URI"] = config.tos_uri
app.config["PRIVACY_URI"] = config.privacy_uri
app.config["ABUSE_EMAIL"] = config.abuse_email
app.config["SECURITY_EMAIL"] = config.security_email
app.context_processor(proc) app.context_processor(proc)
app.register_error_handler( app.register_error_handler(
aiohttp.ClientConnectorError, aiohttp.ClientConnectorError,
backend_error_handler, backend_error_handler, # type:ignore
) )
app.register_error_handler( app.register_error_handler(
werkzeug.exceptions.HTTPException, quart.exceptions.HTTPException,
generic_http_error, # type:ignore generic_http_error, # type:ignore
) )
app.register_error_handler( app.register_error_handler(
Exception, Exception,
generic_error_handler, generic_error_handler, # type:ignore
) )
@app.route("/") @app.route("/")
async def index() -> werkzeug.Response: async def index() -> quart.Response:
if infra.client.has_session: if infra.client.has_session:
return redirect(url_for('user.index')) return redirect(url_for('user.index'))

View File

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

View File

@@ -1,17 +1,14 @@
import json import json
import resource
import time
import typing import typing
from datetime import datetime from datetime import datetime
import aiohttp import aiohttp
import werkzeug.exceptions
import quart.flask_patch import quart.flask_patch
import wtforms import wtforms
import wtforms.fields.html5
from quart import ( from quart import (
Blueprint, Blueprint,
@@ -21,12 +18,11 @@ from quart import (
request, request,
abort, abort,
flash, flash,
current_app,
) )
from flask_babel import lazy_gettext as _l, _ from flask_babel import lazy_gettext as _l, _
from . import prosodyclient, _version from . import prosodyclient
from .infra import client, circle_name, BaseForm from .infra import client, circle_name, BaseForm
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -35,11 +31,7 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/") @bp.route("/")
@client.require_admin_session() @client.require_admin_session()
async def index() -> str: async def index() -> str:
show_metrics = current_app.config["SHOW_METRICS"] return await render_template("admin_home.html")
return await render_template(
"admin_home.html",
show_metrics=show_metrics,
)
class PasswordResetLinkPost(BaseForm): class PasswordResetLinkPost(BaseForm):
@@ -64,6 +56,9 @@ async def users() -> str:
) )
_LIMITED_ROLE_NAME = _("Limited")
class EditUserForm(BaseForm): class EditUserForm(BaseForm):
localpart = wtforms.StringField( localpart = wtforms.StringField(
_l("Login name"), _l("Login name"),
@@ -76,8 +71,10 @@ class EditUserForm(BaseForm):
role = wtforms.RadioField( role = wtforms.RadioField(
_l("Access Level"), _l("Access Level"),
choices=[ choices=[
("prosody:restricted", _("Limited")), # NOTE: enable this only after something has been done which
("prosody:registered", _l("Normal user")), # actually enforces the described restrictions :).
# ("prosody:restricted", _LIMITED_ROLE_NAME),
("prosody:normal", _l("Normal user")),
("prosody:admin", _l("Administrator")), ("prosody:admin", _l("Administrator")),
], ],
) )
@@ -86,14 +83,6 @@ class EditUserForm(BaseForm):
_l("Update user"), _l("Update user"),
) )
action_restore = wtforms.SubmitField(
_l("Restore account"),
)
action_enable = wtforms.SubmitField(
_l("Unlock account"),
)
action_create_reset = wtforms.SubmitField( action_create_reset = wtforms.SubmitField(
_l("Create password reset link"), _l("Create password reset link"),
) )
@@ -101,7 +90,7 @@ class EditUserForm(BaseForm):
@bp.route("/user/<localpart>/", methods=["GET", "POST"]) @bp.route("/user/<localpart>/", methods=["GET", "POST"])
@client.require_admin_session() @client.require_admin_session()
async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]: async def edit_user(localpart: str) -> typing.Union[quart.Response, str]:
target_user_info = await client.get_user_by_localpart(localpart) target_user_info = await client.get_user_by_localpart(localpart)
form = EditUserForm() form = EditUserForm()
@@ -120,44 +109,18 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
".user_password_reset_link", ".user_password_reset_link",
id_=reset_link.id_, id_=reset_link.id_,
)) ))
elif form.action_restore.data or form.action_enable.data:
await client.enable_user_account(localpart)
try:
if form.action_restore.data:
await flash(
_("User account restored"),
"success",
)
else:
await flash(
_("User account unlocked"),
"success",
)
return redirect(url_for(".users"))
except aiohttp.ClientResponseError:
if form.action_restore.data:
await flash(
_("Could not restore user account"),
"alert",
)
else:
await flash(
_("Could not unlock user account"),
"alert",
)
return redirect(url_for(".edit_user", localpart=localpart))
await client.update_user( await client.update_user(
localpart, localpart,
display_name=form.display_name.data, display_name=form.display_name.data,
role=form.role.data, roles=[form.role.data],
) )
await flash( await flash(
_("User information updated."), _("User information updated."),
"success", "success",
) )
return redirect(url_for(".users")) return redirect(url_for(".edit_user", localpart=localpart))
elif request.method == "GET": elif request.method == "GET":
form.localpart.data = target_user_info.localpart form.localpart.data = target_user_info.localpart
@@ -165,7 +128,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
if target_user_info.roles: if target_user_info.roles:
form.role.data = target_user_info.roles[0] form.role.data = target_user_info.roles[0]
else: else:
form.role.data = "prosody:registered" form.role.data = "prosody:normal"
return await render_template( return await render_template(
"admin_edit_user.html", "admin_edit_user.html",
@@ -182,7 +145,7 @@ class DeleteUserForm(BaseForm):
@bp.route("/user/<localpart>/delete", methods=["GET", "POST"]) @bp.route("/user/<localpart>/delete", methods=["GET", "POST"])
@client.require_admin_session() @client.require_admin_session()
async def delete_user(localpart: str) -> typing.Union[str, werkzeug.Response]: async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
target_user_info = await client.get_user_by_localpart(localpart) target_user_info = await client.get_user_by_localpart(localpart)
form = DeleteUserForm() form = DeleteUserForm()
if form.validate_on_submit(): if form.validate_on_submit():
@@ -221,7 +184,7 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]:
@client.require_admin_session() @client.require_admin_session()
async def user_password_reset_link( async def user_password_reset_link(
id_: str, id_: str,
) -> typing.Union[str, werkzeug.Response]: ) -> typing.Union[str, quart.Response]:
invite_info = await client.get_invite_by_id( invite_info = await client.get_invite_by_id(
id_, id_,
) )
@@ -313,7 +276,7 @@ class InvitePost(BaseForm):
@bp.route("/invitations", methods=["GET", "POST"]) @bp.route("/invitations", methods=["GET", "POST"])
@client.require_admin_session() @client.require_admin_session()
async def invitations() -> typing.Union[str, werkzeug.Response]: async def invitations() -> typing.Union[str, quart.Response]:
invites = sorted( invites = sorted(
( (
invite invite
@@ -359,7 +322,7 @@ class InviteForm(BaseForm):
@bp.route("/invitation/-/new", methods=["POST"]) @bp.route("/invitation/-/new", methods=["POST"])
@client.require_admin_session() @client.require_admin_session()
async def create_invite() -> typing.Union[str, werkzeug.Response]: async def create_invite() -> typing.Union[str, quart.Response]:
form = InvitePost() form = InvitePost()
circles = await client.list_groups() circles = await client.list_groups()
form.circles.choices = [ form.circles.choices = [
@@ -387,7 +350,7 @@ async def create_invite() -> typing.Union[str, werkzeug.Response]:
@bp.route("/invitation/<id_>", methods=["GET", "POST"]) @bp.route("/invitation/<id_>", methods=["GET", "POST"])
@client.require_admin_session() @client.require_admin_session()
async def edit_invite(id_: str) -> typing.Union[str, werkzeug.Response]: async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
try: try:
invite_info = await client.get_invite_by_id(id_) invite_info = await client.get_invite_by_id(id_)
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
@@ -453,7 +416,7 @@ async def circles() -> str:
@bp.route("/circle/-/new", methods=["POST"]) @bp.route("/circle/-/new", methods=["POST"])
@client.require_admin_session() @client.require_admin_session()
async def create_circle() -> typing.Union[str, werkzeug.Response]: async def create_circle() -> typing.Union[str, quart.Response]:
create_form = CirclePost() create_form = CirclePost()
if create_form.validate_on_submit(): if create_form.validate_on_submit():
circle = await client.create_group( circle = await client.create_group(
@@ -486,18 +449,20 @@ class EditCircleForm(BaseForm):
_l("Update circle") _l("Update circle")
) )
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
action_remove_user = wtforms.StringField() action_remove_user = wtforms.StringField()
action_add_user = wtforms.SubmitField( action_add_user = wtforms.SubmitField(
_l("Add user") _l("Add user")
) )
action_remove_group_chat = wtforms.StringField()
@bp.route("/circle/<id_>", methods=["GET", "POST"]) @bp.route("/circle/<id_>", methods=["GET", "POST"])
@client.require_admin_session() @client.require_admin_session()
async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]: async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
async with client.authenticated_session() as session: async with client.authenticated_session() as session:
try: try:
circle = await client.get_group_by_id( circle = await client.get_group_by_id(
@@ -513,21 +478,21 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
return redirect(url_for(".circles")) return redirect(url_for(".circles"))
raise raise
users = { users = sorted(
user.localpart: user await client.list_users(),
for user in await client.list_users() key=lambda x: x.localpart
} )
circle_members = [ circle_members = [
(localpart, users.get(localpart)) user for user in users
for localpart in sorted(circle.members) if user.localpart in circle.members
] ]
form = EditCircleForm() form = EditCircleForm()
form.user_to_add.choices = sorted( form.user_to_add.choices = [
(localpart, localpart) (user.localpart, user.localpart)
for localpart in users.keys() for user in users
if localpart not in circle.members if user.localpart not in circle.members
) ]
valid_users = [x[0] for x in form.user_to_add.choices] valid_users = [x[0] for x in form.user_to_add.choices]
invite_form = InvitePost() invite_form = InvitePost()
@@ -547,6 +512,13 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
_("Circle data updated"), _("Circle data updated"),
"success", "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: elif form.action_add_user.data:
if form.user_to_add.data in valid_users: if form.user_to_add.data in valid_users:
await client.add_group_member( await client.add_group_member(
@@ -566,264 +538,15 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
_("User removed from circle"), _("User removed from circle"),
"success", "success",
) )
elif form.action_remove_group_chat.data:
await client.remove_group_chat(
id_,
form.action_remove_group_chat.data,
)
await flash(
_("Chat removed from circle"),
"success",
)
return redirect(url_for(".edit_circle", id_=id_)) return redirect(url_for(".edit_circle", id_=id_))
else:
print(form.errors)
return await render_template( return await render_template(
"admin_edit_circle.html", "admin_edit_circle.html",
target_circle=circle, target_circle=circle,
form=form, form=form,
circle_chats=circle.chats,
circle_members=circle_members, circle_members=circle_members,
invite_form=invite_form, invite_form=invite_form,
) )
class DeleteCircleForm(BaseForm):
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
@bp.route("/circle/<id_>/delete", methods=["GET", "POST"])
@client.require_admin_session()
async def delete_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
id_,
session=session,
)
except aiohttp.ClientResponseError as exc:
if exc.status == 404:
await flash(
_("No such circle exists"),
"alert",
)
return redirect(url_for(".circles"))
raise
form = DeleteCircleForm()
if form.validate_on_submit():
if form.action_delete.data:
await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles"))
return await render_template(
"admin_delete_circle.html",
target_circle=circle,
form=form,
)
class AddCircleChatForm(BaseForm):
name = wtforms.StringField(
_l("Group chat name"),
validators=[wtforms.validators.InputRequired()],
)
action_save = wtforms.SubmitField(
_l("Create group chat")
)
@bp.route("/circle/<id_>/add_chat", methods=["GET", "POST"])
@client.require_admin_session()
async def edit_circle_add_chat(
id_: str
) -> typing.Union[str, werkzeug.Response]:
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
id_,
session=session,
)
except aiohttp.ClientResponseError as exc:
if exc.status == 404:
await flash(
_("No such circle exists"),
"alert",
)
return redirect(url_for(".circles"))
raise
form = AddCircleChatForm()
if form.validate_on_submit():
if form.action_save.data:
await client.add_group_chat(id_, form.name.data)
await flash(
_("New group chat added to circle"),
"success",
)
return redirect(url_for(".edit_circle", id_=id_))
return await render_template(
"admin_create_circle_chat.html",
target_circle=circle,
group_chat_form=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, werkzeug.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 werkzeug.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,
)

View File

@@ -1,22 +1,17 @@
import base64 import base64
import itertools import itertools
import math
import secrets import secrets
import typing import typing
from datetime import datetime, timedelta, timezone
import quart.flask_patch # noqa:F401 import quart.flask_patch # noqa:F401
from quart import ( from quart import (
current_app, current_app,
request, request,
g,
) )
import flask_babel import flask_babel
import flask_wtf import flask_wtf
from flask_babel import lazy_gettext as _l from flask_babel import _
import flask_babel as _
from . import prosodyclient from . import prosodyclient
@@ -27,18 +22,8 @@ client.default_login_redirect = "main.login"
babel = flask_babel.Babel() babel = flask_babel.Babel()
BYTE_UNIT_SCALE_MAP = [
"B",
"kiB",
"MiB",
"GiB",
"TiB",
]
@babel.localeselector # type:ignore @babel.localeselector # type:ignore
def selected_locale() -> str: def selected_locale() -> str:
g.language_header_accessed = True
selected = request.accept_languages.best_match( selected = request.accept_languages.best_match(
current_app.config['LANGUAGES'] current_app.config['LANGUAGES']
) or current_app.config['LANGUAGES'][0] ) or current_app.config['LANGUAGES'][0]
@@ -53,82 +38,18 @@ def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
def circle_name(c: typing.Any) -> str: def circle_name(c: typing.Any) -> str:
if c.id_ == "default" and c.name == "default": if c.id_ == "default" and c.name == "default":
return _l("Main") return _("Main")
return c.name 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 format_last_activity(timestamp: typing.Optional[int]) -> str:
if timestamp is None:
return _l("Never")
last_active = datetime.fromtimestamp(timestamp, tz=timezone.utc)
# TODO: This 'now' should use the user's local time zone, but we
# don't have that information. Thus 'today'/'yesterday' may be
# slightly inaccurate, but compared to alternative solutions it
# should hopefully be "good enough".
now = datetime.now(tz=timezone.utc)
time_ago = now - last_active
yesterday = now - timedelta(days=1)
if (
last_active.year == now.year
and last_active.month == now.month
and last_active.day == now.day
):
return _l("Today")
elif (
last_active.year == yesterday.year
and last_active.month == yesterday.month
and last_active.day == yesterday.day
):
return _l("Yesterday")
return _.gettext(
"%(time)s ago",
time=flask_babel.format_timedelta(time_ago, granularity="day"),
)
def template_now() -> typing.Dict[str, typing.Any]:
return dict(now=lambda: datetime.now(timezone.utc))
def add_vary_language_header(resp: quart.Response) -> quart.Response:
if getattr(g, "language_header_accessed", False):
resp.vary.add("Accept-Language")
return resp
def init_templating(app: quart.Quart) -> None: def init_templating(app: quart.Quart) -> None:
app.template_filter("repr")(repr) app.template_filter("repr")(repr)
app.template_filter("format_datetime")(flask_babel.format_datetime) app.template_filter("format_datetime")(flask_babel.format_datetime)
app.template_filter("format_date")(flask_babel.format_date) app.template_filter("format_date")(flask_babel.format_date)
app.template_filter("format_time")(flask_babel.format_time) app.template_filter("format_time")(flask_babel.format_time)
app.template_filter("format_timedelta")(flask_babel.format_timedelta) app.template_filter("format_timedelta")(flask_babel.format_timedelta)
app.template_filter("format_percent")(flask_babel.format_percent)
app.template_filter("format_bytes")(format_bytes)
app.template_filter("flatten")(flatten) app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name) app.template_filter("circle_name")(circle_name)
app.template_filter("format_last_activity")(format_last_activity)
app.context_processor(template_now)
app.after_request(add_vary_language_header)
def generate_error_id() -> str: def generate_error_id() -> str:

View File

@@ -10,16 +10,13 @@ from quart import (
current_app, current_app,
render_template, render_template,
redirect, redirect,
request,
url_for, url_for,
session as http_session, session as http_session,
) )
import werkzeug
import wtforms import wtforms
from flask_babel import lazy_gettext as _l, gettext from flask_babel import lazy_gettext as _l
from .infra import client, selected_locale, BaseForm from .infra import client, selected_locale, BaseForm
@@ -29,11 +26,6 @@ bp = Blueprint("invite", __name__)
INVITE_SESSION_JID = "invite-session-jid" 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 # 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,14 +40,14 @@ def apple_store_badge() -> str:
@bp.context_processor @bp.context_processor
def context() -> typing.Dict[str, typing.Any]: def context() -> typing.Mapping[str, typing.Any]:
return { return {
"apple_store_badge": apple_store_badge, "apple_store_badge": apple_store_badge,
} }
@bp.route("/<id_>") @bp.route("/<id_>")
async def view_old(id_: str) -> werkzeug.Response: async def view_old(id_: str) -> quart.Response:
return redirect(url_for(".view", id_=id_)) return redirect(url_for(".view", id_=id_))
@@ -104,7 +96,7 @@ async def view(id_: str) -> typing.Union[quart.Response,
return quart.Response( return quart.Response(
body, body,
headers={ headers={
"Link": "<{}>; rel=\"alternate\"".format(invite.xmpp_uri), "Link": "<{}> rel=\"alternate\"".format(invite.xmpp_uri),
} }
) )
@@ -133,7 +125,7 @@ class RegisterForm(BaseForm):
@bp.route("/<id_>/register", methods=["GET", "POST"]) @bp.route("/<id_>/register", methods=["GET", "POST"])
async def register(id_: str) -> typing.Union[str, werkzeug.Response]: async def register(id_: str) -> typing.Union[str, quart.Response]:
try: try:
invite = await client.get_public_invite_by_id(id_) invite = await client.get_public_invite_by_id(id_)
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
@@ -171,7 +163,6 @@ async def register(id_: str) -> typing.Union[str, werkzeug.Response]:
raise raise
else: else:
http_session[INVITE_SESSION_JID] = jid http_session[INVITE_SESSION_JID] = jid
await client.login(jid, form.password.data)
return redirect(url_for(".success")) return redirect(url_for(".success"))
return await render_template( return await render_template(
@@ -201,7 +192,7 @@ class ResetForm(BaseForm):
@bp.route("/<id_>/reset", methods=["GET", "POST"]) @bp.route("/<id_>/reset", methods=["GET", "POST"])
async def reset(id_: str) -> typing.Union[str, werkzeug.Response]: async def reset(id_: str) -> typing.Union[str, quart.Response]:
try: try:
invite = await client.get_public_invite_by_id(id_) invite = await client.get_public_invite_by_id(id_)
except aiohttp.ClientResponseError as exc: except aiohttp.ClientResponseError as exc:
@@ -241,55 +232,11 @@ async def reset(id_: str) -> typing.Union[str, werkzeug.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"]) @bp.route("/success", methods=["GET", "POST"])
@client.require_session()
async def success() -> str: 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( return await render_template(
"invite_success.html", "invite_success.html",
jid=http_session.get(INVITE_SESSION_JID, ""), 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,
) )
@@ -302,5 +249,5 @@ async def reset_success() -> str:
@bp.route("/-") @bp.route("/-")
async def index() -> werkzeug.Response: async def index() -> quart.Response:
return redirect(url_for("index")) return redirect(url_for("index"))

View File

@@ -18,8 +18,6 @@ from quart import (
flash, flash,
) )
import werkzeug.exceptions
import babel import babel
import wtforms import wtforms
@@ -34,7 +32,7 @@ bp = quart.Blueprint("main", __name__)
class LoginForm(BaseForm): class LoginForm(BaseForm):
address = wtforms.StringField( address = wtforms.TextField(
_l("Address"), _l("Address"),
validators=[wtforms.validators.InputRequired()], validators=[wtforms.validators.InputRequired()],
) )
@@ -50,7 +48,7 @@ class LoginForm(BaseForm):
@bp.route("/-") @bp.route("/-")
async def index() -> werkzeug.Response: async def index() -> quart.Response:
return redirect(url_for("index")) return redirect(url_for("index"))
@@ -58,7 +56,7 @@ ERR_CREDENTIALS_INVALID = _l("Invalid username or password.")
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
async def login() -> typing.Union[str, werkzeug.Response]: async def login() -> typing.Union[str, quart.Response]:
if client.has_session and (await client.test_session()): if client.has_session and (await client.test_session()):
return redirect(url_for('user.index')) return redirect(url_for('user.index'))
@@ -78,7 +76,7 @@ async def login() -> typing.Union[str, werkzeug.Response]:
password = form.password.data password = form.password.data
try: try:
await client.login(jid, password) await client.login(jid, password)
except werkzeug.exceptions.Unauthorized: except quart.exceptions.Unauthorized:
form.password.errors.append(ERR_CREDENTIALS_INVALID) form.password.errors.append(ERR_CREDENTIALS_INVALID)
else: else:
await flash( await flash(
@@ -93,30 +91,24 @@ async def login() -> typing.Union[str, werkzeug.Response]:
@bp.route("/meta/about.html") @bp.route("/meta/about.html")
async def about() -> str: async def about() -> str:
version = None version = None
core_versions = {}
extra_versions = {} extra_versions = {}
if current_app.debug or client.is_admin_session: if current_app.debug or client.is_admin_session:
version = _version.version version = _version.version
try: extra_versions["Quart"] = quart.__version__
core_versions["Prosody"] = await client.get_server_version()
except werkzeug.exceptions.Unauthorized:
core_versions["Prosody"] = "unknown"
if current_app.debug:
extra_versions["aiohttp"] = aiohttp.__version__ extra_versions["aiohttp"] = aiohttp.__version__
extra_versions["babel"] = babel.__version__ extra_versions["babel"] = babel.__version__
extra_versions["wtforms"] = wtforms.__version__ extra_versions["wtforms"] = wtforms.__version__
extra_versions["flask-wtf"] = flask_wtf.__version__ extra_versions["flask-wtf"] = flask_wtf.__version__
try: try:
extra_versions["Prosody"] = await client.get_server_version() extra_versions["Prosody"] = await client.get_server_version()
except werkzeug.exceptions.Unauthorized: except quart.exceptions.Unauthorized:
extra_versions["Prosody"] = "unknown" extra_versions["Prosody"] = "unknown"
return await render_template( return await render_template(
"about.html", "about.html",
version=version, version=version,
extra_versions=extra_versions, extra_versions=extra_versions,
core_versions=core_versions,
) )
@@ -173,42 +165,6 @@ async def avatar(from_: str, code: str) -> quart.Response:
return response return response
@bp.route("/terms")
async def terms() -> Response:
if not current_app.config["TOS_URI"]:
return Response("", 404)
return Response("", status=303, headers={
"Location": current_app.config["TOS_URI"],
})
@bp.route("/privacy")
async def privacy() -> Response:
if not current_app.config["PRIVACY_URI"]:
return Response("", 404)
return Response("", status=303, headers={
"Location": current_app.config["PRIVACY_URI"],
})
# This is linked from the iOS app and about page
@bp.route("/policies/")
async def policies() -> str:
return await render_template(
"policies.html",
)
@bp.route("/.well-known/security.txt")
async def securitytxt() -> Response:
return Response(
await render_template("security.txt"),
mimetype="text/plain;charset=UTF-8",
)
@bp.route("/_health") @bp.route("/_health")
async def health() -> Response: async def health() -> Response:
return Response("STATUS OK", content_type="text/plain") return Response("STATUS OK", content_type="text/plain")

View File

@@ -9,28 +9,24 @@ import types
import typing import typing
import typing_extensions import typing_extensions
from datetime import datetime, timezone from datetime import datetime
import aiohttp import aiohttp
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from quart import ( from quart import (
current_app, session as http_session, abort, redirect, current_app, _app_ctx_stack, session as http_session, abort, redirect,
url_for, url_for,
) )
import quart import quart.exceptions
from flask import g as _app_ctx_stack
import werkzeug.exceptions
from . import xmpputil from . import xmpputil
from .xmpputil import split_jid from .xmpputil import split_jid
SCOPE_DEFAULT = "prosody:registered" SCOPE_DEFAULT = "prosody:scope:default"
SCOPE_ADMIN = "prosody:admin" SCOPE_ADMIN = "prosody:scope:admin"
T = typing.TypeVar("T") T = typing.TypeVar("T")
@@ -42,52 +38,6 @@ class TokenInfo:
scopes: typing.Collection[str] scopes: typing.Collection[str]
@dataclasses.dataclass(frozen=True)
class UserDeletionRequestInfo:
deleted_at: datetime
pending_until: datetime
@classmethod
def from_api_response(
cls,
data: typing.Optional[typing.Mapping[str, typing.Any]],
) -> typing.Optional["UserDeletionRequestInfo"]:
if data is None:
return None
return cls(
deleted_at=datetime.fromtimestamp(
data["deleted_at"],
tz=timezone.utc
),
pending_until=datetime.fromtimestamp(
data["pending_until"],
tz=timezone.utc
)
)
@dataclasses.dataclass(frozen=True)
class AvatarMetadata:
bytes: int
hash: str
type: str
width: typing.Optional[int]
height: typing.Optional[int]
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AvatarMetadata":
return cls(
hash=data["hash"],
bytes=data["bytes"],
type=data["type"],
width=data.get("width") or None,
height=data.get("height") or None,
)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class AdminUserInfo: class AdminUserInfo:
localpart: str localpart: str
@@ -95,10 +45,6 @@ class AdminUserInfo:
email: typing.Optional[str] email: typing.Optional[str]
phone: typing.Optional[str] phone: typing.Optional[str]
roles: typing.Optional[typing.List[str]] roles: typing.Optional[typing.List[str]]
enabled: bool
last_active: typing.Optional[int]
deletion_request: typing.Optional[UserDeletionRequestInfo]
avatar_info: typing.List[AvatarMetadata]
@property @property
def has_admin_role(self) -> bool: def has_admin_role(self) -> bool:
@@ -113,27 +59,12 @@ class AdminUserInfo:
cls, cls,
data: typing.Mapping[str, typing.Any], data: typing.Mapping[str, typing.Any],
) -> "AdminUserInfo": ) -> "AdminUserInfo":
try:
roles: typing.Optional[typing.List[str]] = [data["role"]]
assert roles is not None # make mypy happy
roles.extend(data.get("secondary_roles", []))
except KeyError:
roles = data.get("roles")
return cls( return cls(
localpart=data["username"], localpart=data["username"],
display_name=data.get("display_name") or None, display_name=data.get("display_name") or None,
email=data.get("email") or None, email=data.get("email") or None,
phone=data.get("phone") or None, phone=data.get("phone") or None,
roles=roles, roles=data.get("roles"),
enabled=data.get("enabled", True),
last_active=data.get("last_active") or None,
deletion_request=UserDeletionRequestInfo.from_api_response(
data.get("deletion_request")
),
avatar_info=[
AvatarMetadata.from_api_response(avatar_info)
for avatar_info in data.get("avatar_info", [])
],
) )
@@ -176,30 +107,12 @@ class AdminInviteInfo:
) )
@dataclasses.dataclass(frozen=True)
class AdminGroupChatInfo:
id_: str
jid: str
name: str
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminGroupChatInfo":
return cls(
id_=data["id"],
jid=data["jid"],
name=data.get("name", ""),
)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class AdminGroupInfo: class AdminGroupInfo:
id_: str id_: str
name: str name: str
muc_jid: typing.Optional[str]
members: typing.Collection[str] members: typing.Collection[str]
chats: typing.Collection[AdminGroupChatInfo]
@classmethod @classmethod
def from_api_response( def from_api_response(
@@ -209,11 +122,8 @@ class AdminGroupInfo:
return cls( return cls(
id_=data["id"], id_=data["id"],
name=data["name"], name=data["name"],
muc_jid=data.get("muc_jid") or None,
members=data.get("members", []), members=data.get("members", []),
chats=[
AdminGroupChatInfo.from_api_response(x)
for x in data.get("chats", [])
]
) )
@@ -248,7 +158,7 @@ class HTTPSessionManager:
}) })
async def teardown(self, exc: typing.Optional[BaseException]) -> None: async def teardown(self, exc: typing.Optional[BaseException]) -> None:
app_ctx = _app_ctx_stack app_ctx = _app_ctx_stack.top
try: try:
session = getattr(app_ctx, self._app_context_attribute) session = getattr(app_ctx, self._app_context_attribute)
except AttributeError: except AttributeError:
@@ -265,7 +175,7 @@ class HTTPSessionManager:
await session.__aexit__(exc_type, exc, traceback) await session.__aexit__(exc_type, exc, traceback)
async def __aenter__(self) -> aiohttp.ClientSession: async def __aenter__(self) -> aiohttp.ClientSession:
app_ctx = _app_ctx_stack app_ctx = _app_ctx_stack.top
try: try:
return getattr(app_ctx, self._app_context_attribute) return getattr(app_ctx, self._app_context_attribute)
except AttributeError: except AttributeError:
@@ -386,9 +296,6 @@ class ProsodyClient:
def _public_v1_endpoint(self, subpath: str) -> str: def _public_v1_endpoint(self, subpath: str) -> str:
return "{}/register_api{}".format(self._endpoint_base, subpath) 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, async def _oauth2_bearer_token(self,
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
jid: str, jid: str,
@@ -476,16 +383,16 @@ class ProsodyClient:
) -> typing.Callable[ ) -> typing.Callable[
[typing.Callable[..., typing.Awaitable[T]]], [typing.Callable[..., typing.Awaitable[T]]],
typing.Callable[..., typing.Awaitable[ typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response, werkzeug.Response]]]]: typing.Union[T, quart.Response]]]]:
def decorator( def decorator(
f: typing.Callable[..., typing.Awaitable[T]], f: typing.Callable[..., typing.Awaitable[T]],
) -> typing.Callable[..., typing.Awaitable[ ) -> typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response, werkzeug.Response]]]: typing.Union[T, quart.Response]]]:
@functools.wraps(f) @functools.wraps(f)
async def wrapped( async def wrapped(
*args: typing.Any, *args: typing.Any,
**kwargs: typing.Any, **kwargs: typing.Any,
) -> typing.Union[T, quart.Response, werkzeug.Response]: ) -> typing.Union[T, quart.Response]:
if not self.has_session or not (await self.test_session()): if not self.has_session or not (await self.test_session()):
redirect_to_value = redirect_to redirect_to_value = redirect_to
if redirect_to_value is not False: if redirect_to_value is not False:
@@ -505,17 +412,17 @@ class ProsodyClient:
) -> typing.Callable[ ) -> typing.Callable[
[typing.Callable[..., typing.Awaitable[T]]], [typing.Callable[..., typing.Awaitable[T]]],
typing.Callable[..., typing.Awaitable[ typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response, werkzeug.Response]]]]: typing.Union[T, quart.Response]]]]:
def decorator( def decorator(
f: typing.Callable[..., typing.Awaitable[T]], f: typing.Callable[..., typing.Awaitable[T]],
) -> typing.Callable[..., typing.Awaitable[ ) -> typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response, werkzeug.Response]]]: typing.Union[T, quart.Response]]]:
@functools.wraps(f) @functools.wraps(f)
@self.require_session(redirect_to=redirect_to) @self.require_session(redirect_to=redirect_to)
async def wrapped( async def wrapped(
*args: typing.Any, *args: typing.Any,
**kwargs: typing.Any, **kwargs: typing.Any,
) -> typing.Union[T, quart.Response, werkzeug.Response]: ) -> typing.Union[T, quart.Response]:
if not self.is_admin_session: if not self.is_admin_session:
raise abort(403, "This is not for you.") raise abort(403, "This is not for you.")
@@ -582,7 +489,7 @@ class ProsodyClient:
session=session, session=session,
) )
avatar_hash = avatar_info["sha1"] avatar_hash = avatar_info["sha1"]
except werkzeug.exceptions.HTTPException: except quart.exceptions.HTTPException:
avatar_hash = None avatar_hash = None
return { return {
@@ -734,7 +641,7 @@ class ProsodyClient:
new_access_model, new_access_model,
) )
)) ))
except werkzeug.exceptions.NotFound: except quart.exceptions.NotFound:
if ignore_not_found: if ignore_not_found:
return return
raise raise
@@ -864,7 +771,7 @@ class ProsodyClient:
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
) -> str: ) -> str:
access_models = filter( access_models = filter(
lambda x: not isinstance(x, werkzeug.exceptions.NotFound), lambda x: not isinstance(x, quart.exceptions.NotFound),
await asyncio.gather( await asyncio.gather(
self.get_avatar_access_model(session=session), self.get_avatar_access_model(session=session),
self.get_nickname_access_model(session=session), self.get_nickname_access_model(session=session),
@@ -967,7 +874,7 @@ class ProsodyClient:
localpart: str, localpart: str,
*, *,
display_name: typing.Optional[str], display_name: typing.Optional[str],
role: typing.Optional[str], roles: typing.Optional[typing.Collection[str]],
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
) -> None: ) -> None:
payload: typing.Dict[str, typing.Any] = { payload: typing.Dict[str, typing.Any] = {
@@ -975,8 +882,8 @@ class ProsodyClient:
} }
if display_name is not None: if display_name is not None:
payload["display_name"] = display_name payload["display_name"] = display_name
if role is not None: if roles is not None:
payload["role"] = role payload["roles"] = list(roles)
async with session.put( async with session.put(
self._admin_v1_endpoint("/users/{}".format(localpart)), self._admin_v1_endpoint("/users/{}".format(localpart)),
@@ -984,36 +891,6 @@ class ProsodyClient:
) as resp: ) as resp:
self._raise_error_from_response(resp) self._raise_error_from_response(resp)
@autosession
async def enable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": True,
},
) as resp:
self._raise_error_from_response(resp)
@autosession
async def disable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": False,
},
) as resp:
self._raise_error_from_response(resp)
@autosession @autosession
async def get_user_debug_info( async def get_user_debug_info(
self, self,
@@ -1142,7 +1019,7 @@ class ProsodyClient:
self, self,
name: str, name: str,
*, *,
create_muc: bool = False, create_muc: bool = True,
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
) -> AdminGroupInfo: ) -> AdminGroupInfo:
payload = { payload = {
@@ -1217,27 +1094,6 @@ class ProsodyClient:
) as resp: ) as resp:
self._raise_error_from_response(resp) self._raise_error_from_response(resp)
@autosession
async def add_group_chat(
self,
id_: str,
name: str,
*,
session: aiohttp.ClientSession,
) -> None:
payload: typing.Dict[str, typing.Any] = {
"name": name,
}
async with session.post(
self._admin_v1_endpoint(
"/groups/{}/chats".format(id_)
),
json=payload,
) as resp:
self._raise_error_from_response(resp)
@autosession @autosession
async def remove_group_member( async def remove_group_member(
self, self,
@@ -1253,21 +1109,6 @@ class ProsodyClient:
) as resp: ) as resp:
self._raise_error_from_response(resp) self._raise_error_from_response(resp)
@autosession
async def remove_group_chat(
self,
group_id: str,
chat_id: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint(
"/groups/{}/chats/{}".format(group_id, chat_id)
),
) as resp:
self._raise_error_from_response(resp)
@autosession @autosession
async def delete_group( async def delete_group(
self, self,
@@ -1281,33 +1122,6 @@ class ProsodyClient:
self._raise_error_from_response(resp) self._raise_error_from_response(resp)
@autosession @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
async def revoke_token( async def revoke_token(
self, self,
*, *,
@@ -1321,8 +1135,7 @@ class ProsodyClient:
async def logout(self) -> None: async def logout(self) -> None:
try: try:
async with self._plain_session as session: await self.revoke_token()
await self.revoke_token(session=session)
except aiohttp.ClientError: except aiohttp.ClientError:
self.logger.warn("failed to revoke token!", self.logger.warn("failed to revoke token!",
exc_info=True) exc_info=True)
@@ -1362,41 +1175,3 @@ class ProsodyClient:
json=payload) as resp: json=payload) as resp:
resp.raise_for_status() resp.raise_for_status()
return (await resp.json())["jid"] return (await resp.json())["jid"]
@autosession
async def get_system_metrics(
self,
*,
session: aiohttp.ClientSession) -> typing.Mapping:
async with session.get(
self._admin_v1_endpoint("/server/metrics"),
) as resp:
if resp.status == 404:
return {}
self._raise_error_from_response(resp)
resp.raise_for_status()
return await resp.json()
@autosession
async def post_announcement(
self,
body: str,
recipients: str,
*,
session: aiohttp.ClientSession) -> None:
recipients_payload: typing.Union[str, typing.Sequence[str]]
if recipients == "self":
recipients_payload = [self.session_address]
else:
recipients_payload = recipients
payload = {
"recipients": recipients_payload,
"body": body,
}
async with session.post(
self._admin_v1_endpoint("/server/announcement"),
json=payload) as resp:
self._raise_error_from_response(resp)
resp.raise_for_status()

View File

@@ -275,22 +275,22 @@ div.form.layout-expanded {
} }
@each $type in $text-entry-inputs { @each $type in $text-entry-inputs {
input[type=#{$type}] { input[type=$type] {
width: 100%; width: 100%;
border: none; border: none;
border-bottom: $w-s4 solid $primary-500; border-bottom: $w-s4 solid $primary-500;
margin-bottom: -$w-s4; margin-bottom: -$w-s4;
} }
input[type=#{$type}].has-error { input[type=$type].has-error {
border-right: $w-s4 solid $alert-500; border-right: $w-s4 solid $alert-500;
} }
input[type=#{$type}]:hover { input[type=$type]:hover {
border-bottom-color: $primary-700; border-bottom-color: $primary-700;
} }
input[type=#{$type}]:focus { input[type=$type]:focus {
border-bottom-color: $primary-800; border-bottom-color: $primary-800;
} }
} }
@@ -646,6 +646,69 @@ input[type="submit"], button, .button {
/* button, .button {
margin: 0 $w-s2;
}
button.lv-primary, .button.lv-primary {
background-color: $gray-500;
color: $gray-900;
border-radius: $w-s4;
border: $w-s4 solid $gray-400;
@each $type, $values in $colours {
&.c-#{$type} {
border-color: nth($values, 4);
background-color: nth($values, 5);
color: nth($values, 9);
}
&.c-#{$type}:hover {
background-color: nth($values, 4);
}
}
}
button.lv-secondary, .button.lv-secondary {
background-color: $gray-700;
color: $gray-100;
border-radius: $w-s4;
@each $type, $values in $colours {
&.c-#{$type} {
background-color: nth($values, 7);
color: nth($values, 1);
}
}
}
button.lv-tertiary, .button.lv-tertiary {
background-color: inherit;
color: $gray-300;
border-radius: $w-s4;
text-decoration: underline;
@each $type, $values in $colours {
&.c-#{$type} {
color: nth($values, 3);
}
}
}
*/
/*
button.lv-secondary.c-#{$type}, .button.lv-secondary.c-#{$type} {
background-color: nth($values, 7);
color: nth($values, 1);
}
button.lv-tertiary.c-#{$type}, .button.lv-tertiary.c-#{$type} {
color: nth($values, 3);
text-decoration: underline;
background-color: transparent;
}
}*/
/* boxes */ /* boxes */
.box { .box {
@@ -708,7 +771,8 @@ input[type="submit"], button, .button {
height: 1.5em; height: 1.5em;
vertical-align: middle; vertical-align: middle;
background-size: cover; background-size: cover;
border-radius: 10%; box-shadow: inset 0px 0px 0px 2px rgba(0, 0, 0, 0.2);
border-radius: $w-s4;
margin: 0 0.25em; margin: 0 0.25em;
@@ -1057,7 +1121,7 @@ pre.guru-meditation {
} }
@each $type in $text-entry-inputs { @each $type in $text-entry-inputs {
input[type=#{$type}] { input[type=$type] {
background-color: black; background-color: black;
} }
@@ -1067,10 +1131,6 @@ pre.guru-meditation {
} }
} }
label, legend {
color: $gray-800 !important;
}
.box { .box {
background-color: black; background-color: black;
border-color: $gray-800; border-color: $gray-800;
@@ -1205,13 +1265,6 @@ pre.guru-meditation {
p.form-desc.weak, p.field-desc.weak { p.form-desc.weak, p.field-desc.weak {
color: $gray-700; color: $gray-700;
} }
.user-badge-icon {
color: $gray-900 !important;
background-color: $gray-100 !important;
border-color: $gray-300 !important;
box-shadow: black 0 0 2px !important;
}
} }
/* tooltip magic */ /* tooltip magic */
@@ -1262,46 +1315,3 @@ pre.guru-meditation {
.with-tooltip:hover:before, .with-tooltip:hover:after { .with-tooltip:hover:before, .with-tooltip:hover:after {
display: block; display: block;
} }
.username-with-avatar {
display: flex;
align-items: center;
.avatar-container {
position: relative;
.avatar {
margin-left: 0;
}
}
.user-badge-icon {
position: absolute;
bottom: -10px;
right: 0px;
background: white;
border-radius: 50%;
width: 1.2em;
height: 1.2em;
border-color: $gray-500;
border-width: 1px;
border-style: solid;
text-align: center;
margin: 0;
padding: 0;
margin: 0;
padding: 0;
box-shadow: $gray-500 0px 0px 2px;
line-height: 1;
.icon {
/* vertical-align: text-bottom; */
padding: 0.1em;
}
}
.user-info-container {
margin-left: 0.5em;
}
}

View File

@@ -80,6 +80,60 @@ img.fdroid {
height: $w-l3; height: $w-l3;
} }
.tabbox {
display: flex;
flex-direction: column;
margin: $w-l1 0;
> nav.tabs {
display: flex;
flex-direction: row;
> a {
display: inline-block;
padding: $w-s2;
border-top-left-radius: $w-s4;
border-top-right-radius: $w-s4;
&, &:visited {
color: inherit;
text-decoration: underline;
text-decoration-color: $accent-500;
}
&:hover {
background: $accent-900;
border-color: $accent-800;
color: black;
}
&.active {
text-decoration: none;
background: linear-gradient(0deg, $accent-600, $accent-700);
color: $accent-200;
&:hover, &:focus {
background: linear-gradient(0deg, $accent-700, $accent-800);
}
&:active {
background: $accent-600;
}
}
}
}
> .tab-pane {
display: none;
padding: 0 $w-0;
background: $accent-900;
&.active {
display: block;
}
}
}
.qr { .qr {
margin: $w-l1 0; margin: $w-l1 0;
display: flex; display: flex;

View File

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

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -6,32 +6,24 @@
{% block body %} {% block body %}
<main> <main>
<div class="box el-2"> <div class="box el-2">
<h2>{% trans %}About this Service{% endtrans %}</h2> <h1>{% trans %}About Snikket{% endtrans %}</h1>
<p>{% trans site_name=config["SITE_NAME"] %}This is the Snikket service <em>{{ site_name }}</em>, running open-source software from the Snikket project.{% endtrans %}</p>
<p>{% trans snikket_url="https://snikket.org" %}To learn more about Snikket, visit the <a href="{{ snikket_url}}">Snikket website</a>.{% endtrans %}</p> <p>{% trans snikket_url="https://snikket.org" %}To learn more about Snikket, visit the <a href="{{ snikket_url}}">Snikket website</a>.{% endtrans %}</p>
<h2>{% trans %}About this Service{% endtrans %}</h2>
<p><a href="/policies/">{% trans %}View service policies{% endtrans %}</a> <p>{% trans site_name=config["SITE_NAME"] %}This is the Snikket service <em>{{ site_name }}</em>.{% endtrans %}</p>
<h3>{% trans %}Licenses{% endtrans %}</h3> <h3>{% trans %}Licenses{% endtrans %}</h3>
<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 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://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 }}">Googles Material Icons</a>, made available by Google under the terms of the <a href="{{ apache20_url }}">Apache 2.0 License</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 }}">Googles 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> <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 %} <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> <h3>{% trans %}Software Versions{% endtrans %}</h3>
<pre>Domain: {{ config["SNIKKET_DOMAIN"] }} <pre>Snikket Server
Web Portal{% if version %} ({{ version }}){% endif %} Domain: {{ config["SNIKKET_DOMAIN"] }}
{%- if core_versions -%} Snikket Web Portal{% if version %} ({{ version }}){% endif %}
{% for name, version in core_versions.items() %}
{{ name }} ({{ version }}){% endfor %}
{%- endif -%}
{%- if extra_versions -%} {%- if extra_versions -%}
{% for name, version in extra_versions.items() %} {% for name, version in extra_versions.items() %}
{{ name }} ({{ version }}){% endfor %} {{ name }} ({{ version }}){% endfor %}
{%- endif -%}</pre> {%- endif -%}</pre>
<p> <p>
{%- call standard_button("back", url_for("index"), class="primary") -%} {%- call standard_button("back", url_for("index"), class="primary") -%}
{% trans %}Back to the main page{% endtrans %} {% trans %}Back to the main page{% endtrans %}

View File

@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<h1>{% trans %}Manage circles{% endtrans %}</h1> <h1>{% trans %}Manage circles{% endtrans %}</h1>
<p>{% trans %}<em>Circles</em> aim to help people who are in the same social circle find each other on your service.{% endtrans %}</p> <p>{% trans %}<em>Circles</em> aim to help people who are in the same social circle find each other on your service.{% endtrans %}</p>
<p>{% trans %}Users who are in the same circle will see each other in their contact list. In addition, each circle may have group chats where the circle members are included.{% endtrans %}</p> <p>{% trans %}Users who are in the same circle will see each other in their contact list. In addition, each circle has a group chat where the circle members are included.{% endtrans %}</p>
{%- if circles -%} {%- if circles -%}
<form method="POST" action="{{ url_for(".create_invite") }}"> <form method="POST" action="{{ url_for(".create_invite") }}">
{{- invite_form.csrf_token -}} {{- invite_form.csrf_token -}}

View File

@@ -1,5 +0,0 @@
{% extends "admin_app.html" %}
{% block content %}
<h1>{{ target_circle.name }}</h1>
{%- include "admin_create_circle_group_chat_form.html" -%}
{% endblock %}

View File

@@ -1,15 +0,0 @@
{% from "library.j2" import form_button, render_errors %}
<form method="POST" action="{{ url_for(".edit_circle_add_chat", id_=target_circle.id_) }}">
{{- group_chat_form.csrf_token -}}
<div class="form layout-expanded">
<h2 class="form-title">{% trans %}Create new circle group chat{% endtrans %}</h2>
<p class="form-descr weak">{% trans %}Add a chat to your circle so its members can hold group discussions.{% endtrans %}</p>
<p class="form-descr weak"><strong>{% trans %}Tip:{% endtrans %}</strong> {% trans %}This is only for creating group chats that automatically include <em>all</em> members of the circle. If you want a normal group chat, create it in the Snikket app instead.{% endtrans %}</p>
<div class="f-ebox">
{{ group_chat_form.name.label }}
{{ group_chat_form.name }}
</div>
<div class="f-bbox">
{%- call form_button("add", group_chat_form.action_save, class="primary") %}{% endcall -%}
</div>
</div></form>

View File

@@ -1,21 +0,0 @@
{% extends "admin_app.html" %}
{% from "library.j2" import box, form_button, standard_button %}
{% block content %}
<h1>{% trans circle_name=target_circle.name %}Delete circle {{ circle_name }}{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}Delete circle{% endtrans %}</h2>
{{ form.csrf_token }}
<p class="form-descr">{% trans %}Are you sure you want to delete the following circle?{% endtrans %}</p>
<dl>
<dt>{% trans %}Name{% endtrans %}</dt>
<dd>{{ target_circle.name }}</dd>
</dl>
{% call box("alert", _("Danger")) %}
<p>{% trans %}The circle and the corresponding chat will be deleted, 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(".edit_circle", id_=target_circle.id_), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
</div>
</form></div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends "admin_app.html" %} {% extends "admin_app.html" %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon %} {% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button %}
{% block head_lead %} {% block head_lead %}
{{ super() }} {{ super() }}
{% include "copy-snippet.html" %} {% include "copy-snippet.html" %}
@@ -13,6 +13,13 @@
<div class="box hint form layout-expanded"> <div class="box hint form layout-expanded">
<header>{% trans %}This is your main circle{% endtrans %}</header> <header>{% trans %}This is your main circle{% endtrans %}</header>
<p>{% trans %}This circle is managed automatically and cannot be removed or renamed.{% endtrans %}</p> <p>{% trans %}This circle is managed automatically and cannot be removed or renamed.{% endtrans %}</p>
{%- if target_circle.muc_jid -%}
<div><label for="circle-muc-jid">{% trans %}Group chat address{% endtrans %}</label></div>
<div><input type="text" readonly="readonly" id="circle-muc-jid" value="{{ target_circle.muc_jid }}"></div>
{%- call clipboard_button(target_circle.muc_jid, show_label=True) -%}
{%- trans -%}Copy address{%- endtrans -%}
{%- endcall -%}
{%- endif -%}
</div> </div>
{%- else -%} {%- else -%}
<div class="form layout-expanded"> <div class="form layout-expanded">
@@ -21,6 +28,17 @@
{{ form.name.label }} {{ form.name.label }}
{{ form.name }} {{ form.name }}
</div> </div>
<div class="f-ebox">
{%- if target_circle.muc_jid -%}
<label for="circle-muc-jid">{% trans %}Group chat address{% endtrans %}</label>
<input type="text" readonly="readonly" id="circle-muc-jid" value="{{ target_circle.muc_jid }}">
{%- call clipboard_button(target_circle.muc_jid, show_label=True) -%}
{%- trans -%}Copy address{%- endtrans -%}
{%- endcall -%}
{%- else -%}
<p>{% trans %}This circle has no group chat associated.{% endtrans %}<p>
{%- endif -%}
</div>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%} {%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
{% trans %}Return to circle list{% endtrans %} {% trans %}Return to circle list{% endtrans %}
@@ -30,61 +48,22 @@
<h3 class="form-title">{% trans %}Delete circle{% endtrans %}</h3> <h3 class="form-title">{% trans %}Delete circle{% endtrans %}</h3>
<p class="form-desc">{% trans %}Deleting a circle does not delete any users in the circle.{% endtrans %}</p> <p class="form-desc">{% trans %}Deleting a circle does not delete any users in the circle.{% endtrans %}</p>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("delete", url_for(".delete_circle", id_=target_circle.id_), class="secondary danger") %}{% trans %}Delete circle{% endtrans %}{% endcall -%} {%- call form_button("delete", form.action_delete, class="secondary danger") %}{% endcall -%}
</div> </div>
</div> </div>
{%- endif -%} {%- endif -%}
<h2 id="chats">{% trans %}Group chats{% endtrans %}</h2>
<p>{% trans %}These group chats will be available to all members of the circle.{% endtrans %}</p>
{%- if circle_chats -%}
<div class="el-2 elevated"><table>
<thead>
<th>{% trans %}Name{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</thead>
<tbody>
{%- for chat in circle_chats -%}
<tr>
<td class="collapsible">{% call value_or_hint(chat.name) %}{% endcall %}</td>
<td class="nowrap">
{%- call custom_form_button("delete", form.action_remove_group_chat.name, chat.id_, class="primary danger", slim=True) -%}
{% trans name=chat.name %}Delete group chat '{{ name }}'{% endtrans %}
{%- endcall -%}
</td>
</tr>
{%- endfor -%}
</tbody>
</table></div>
{%- else -%}
<p>{% trans %}This circle currently has no group chats.{% endtrans %}</p>
{%- endif -%}
{%- call standard_button("add", url_for(".edit_circle_add_chat", id_=target_circle.id_), class="secondary") -%}
{% trans %}Add group chat{% endtrans %}
{%- endcall -%}
<h2 id="members">{% trans %}Circle members{% endtrans %}</h2> <h2 id="members">{% trans %}Circle members{% endtrans %}</h2>
<p>{% trans %}All members of the circle will see each other in their contact list.{% endtrans %}</p>
{%- if circle_members -%} {%- if circle_members -%}
<div class="el-2 elevated"><table> <div class="el-2 elevated"><table>
<thead> <thead>
<th>{% trans %}Login name{% endtrans %}</th> <th>Login name</th>
<th class="collapsible">{% trans %}Display name{% endtrans %}</th> <th class="collapsible">Display name</th>
<th>{% trans %}Actions{% endtrans %}</th> <th>Actions</th>
</thead> </thead>
<tbody> <tbody>
{%- for localpart, member in circle_members -%} {%- for member in circle_members -%}
<tr> <tr>
<td> <td>{{ member.localpart }}</td>
{%- if member -%}
{{ localpart }}
{%- else -%}
{{ localpart }}
<span class="with-tooltip above" data-tooltip="{% trans %}The user has been deleted from the server.{% endtrans %}"><em> ({% trans %}deleted{% endtrans %})</em></span>
{%- endif -%}
</td>
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td> <td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
<td class="nowrap"> <td class="nowrap">
{%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%} {%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}

View File

@@ -3,7 +3,7 @@
{% macro access_level_description(role, caller=None) %} {% macro access_level_description(role, caller=None) %}
{%- if role == "prosody:restricted" -%} {%- if role == "prosody:restricted" -%}
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %} {% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
{%- elif role == "prosody:registered" -%} {%- elif role == "prosody:normal" -%}
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %} {% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
{%- elif role == "prosody:admin" -%} {%- elif role == "prosody:admin" -%}
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %} {% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}
@@ -19,33 +19,12 @@
{% block content %} {% block content %}
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1> <h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded"> <form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
{% if target_user.deletion_request %}
<div class="box alert">
<header>{% trans %}This user account is pending deletion{% endtrans %}</header>
<p>{% trans date=target_user.deletion_request.deleted_at | format_datetime %}The owner of the account sent a deletion request on {{ date }} using their app.{% endtrans %}
<p>{% trans time=(target_user.deletion_request.pending_until - now())|format_timedelta %}The account has been locked, and will be automatically deleted permanently in {{ time }}.{% endtrans %}</p>
<p>{% trans %}If this was a mistake, you can cancel the deletion and restore the account.{% endtrans %}</p>
{%- call form_button("restore_from_trash", form.action_restore, class="secondary") %}{% endcall %}
</div>
{% elif not target_user.enabled %}
<div class="box alert">
<header>{% trans %}This user account is locked{% endtrans %}</header>
<p>{% trans %}The user will not be able to log in to their account until it is unlocked again.{% endtrans %}</p>
{%- call form_button("lock_open", form.action_enable, class="secondary") %}{% endcall %}
</div>
{% endif %}
<h2 class="form-title">{% trans %}Edit user{% endtrans %}</h2> <h2 class="form-title">{% trans %}Edit user{% endtrans %}</h2>
<div class="f-ebox"> <div class="f-ebox">
{{ form.localpart.label }} {{ form.localpart.label }}
{{ form.localpart(readonly="readonly") }} {{ form.localpart(readonly="readonly") }}
<p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p> <p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p>
</div> </div>
<div class="f-ebox"> <div class="f-ebox">
{{ form.display_name.label }} {{ form.display_name.label }}
{{ form.display_name }} {{ form.display_name }}
@@ -84,14 +63,14 @@
{% 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 %} {% 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> </p>
<div class="f-bbox"> <div class="f-bbox">
{%- call form_button("passwd", form.action_create_reset, class="secondary") -%}{%- endcall -%} {%- call form_button("passwd", form.action_create_reset, class="primary") -%}{%- endcall -%}
</div> </div>
<h2 class="form-title">{% trans %}Debug information{% endtrans %}</h2> <h2 class="form-title">{% trans %}Debug information{% endtrans %}</h2>
<p class="form-desc"> <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 %} {% 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> </p>
<div class="f-bbox"> <div class="f-bbox">
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="secondary") -%} {%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="primary") -%}
{%- trans -%}Show debug information{%- endtrans -%} {%- trans -%}Show debug information{%- endtrans -%}
{%- endcall -%} {%- endcall -%}
</div> </div>

View File

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

View File

@@ -1,105 +0,0 @@
{% extends "admin_app.html" %}
{% from "library.j2" import form_button %}
{% block content %}
<h1>{% trans %}Manage system{% endtrans %}</h1>
{% if show_metrics %}
<h2>{% trans %}Overall system status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}System load (5 minute average){% endtrans %}</dt>
<dd>
{%- if metrics.load5 -%}
{{ metrics.load5 }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Memory use{% endtrans %}</dt>
<dd>
{%- if metrics.mem_total and metrics.mem_available -%}
{% trans percentage_global=((1 - (metrics.mem_available / metrics.mem_total)) | format_percent), percentage_snikket=((((metrics.prosody_rss | default(0)) + (metrics.portal_rss | default(0))) / metrics.mem_total) | format_percent), mem_available=(metrics.mem_total | format_bytes) %}{{ percentage_global }} of {{ mem_available }}. Of that, Snikket uses {{ percentage_snikket }}.{% endtrans %}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
<h2>{% trans %}Web portal status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}Version{% endtrans %}</dt>
<dd>{{ version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
<dd>
{%- if metrics.portal_cpu -%}
{{ metrics.portal_cpu | format_percent }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Current memory use{% endtrans %}</dt>
<dd>
{%- if metrics.portal_rss -%}
{{ metrics.portal_rss | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
<h2>{% trans %}Snikket server status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}Version{% endtrans %}</dt>
<dd>{{ prosody_version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_cpu -%}
{{ metrics.prosody_cpu | format_percent }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Current memory use{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_rss -%}
{{ metrics.prosody_rss | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}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 %}

View File

@@ -1,12 +1,12 @@
{% extends "admin_app.html" %} {% extends "admin_app.html" %}
{% from "library.j2" import action_button, avatar, icon, render_user, value_or_hint, custom_form_button with context %} {% from "library.j2" import action_button, icon, value_or_hint, custom_form_button %}
{% block content %} {% block content %}
<h1>{% trans %}Manage users{% endtrans %}</h1> <h1>{% trans %}Manage users{% endtrans %}</h1>
<div class="elevated el-2"><table> <div class="elevated el-2"><table>
<thead> <thead>
<tr> <tr>
<th>{% trans %}User{% endtrans %}</th> <th>{% trans %}Login name{% endtrans %}</th>
<th>{% trans %}Last active{% endtrans %}</th> <th>{% trans %}Display name{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
</tr> </tr>
</thead> </thead>
@@ -14,15 +14,15 @@
{% for user in users %} {% for user in users %}
<tr> <tr>
<td> <td>
{%- call render_user(user) -%}{%- endcall -%} {{- 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>
{% if user.enabled %} <td>{% call value_or_hint(user.display_name) %}{% endcall %}</td>
<td>{{ user.last_active | format_last_activity }}</td>
{% elif user.deletion_request %}
<td>{% trans %}Deleted{% endtrans %}</td>
{% else %}
<td>{% trans %}Locked{% endtrans %}</td>
{% endif %}
<td class="nowrap"> <td class="nowrap">
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%} {%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%}
{% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %} {% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %}

View File

@@ -28,12 +28,12 @@
</div> </div>
<div class="f-ebox"> <div class="f-ebox">
{{ form.password.label }} {{ form.password.label }}
{{ form.password(autocomplete="new-password") }} {{ form.password }}
<p class="field-desc weak">{% trans %}Enter a secure password that you do not use anywhere else.{% endtrans %}</p> <p class="field-desc weak">{% trans %}Enter a secure password that you do not use anywhere else.{% endtrans %}</p>
</div> </div>
<div class="f-ebox"> <div class="f-ebox">
{{ form.password_confirm.label }} {{ form.password_confirm.label }}
{{ form.password_confirm(autocomplete="new-password") }} {{ form.password_confirm }}
</div> </div>
<div class="f-bbox"> <div class="f-bbox">
{%- call form_button("done", form.action_register, class="primary") -%}{%- endcall -%} {%- call form_button("done", form.action_register, class="primary") -%}{%- endcall -%}

View File

@@ -7,6 +7,7 @@
{% block head_lead %} {% block head_lead %}
{{ super() }} {{ super() }}
<title>{% trans %}Reset your password | Snikket{% endtrans %}</title> <title>{% trans %}Reset your password | Snikket{% endtrans %}</title>
<script async type="text/javascript" src="{{ url_for("static", filename="js/qrcode.min.js") }}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<form method="POST"><div class="form layout-expanded"> <form method="POST"><div class="form layout-expanded">
@@ -16,14 +17,19 @@
{%- call render_errors(form) %}{% endcall -%} {%- call render_errors(form) %}{% endcall -%}
<div class="f-ebox"> <div class="f-ebox">
{{ form.password.label }} {{ form.password.label }}
{{ form.password(autocomplete="new-password") }} {{ form.password }}
</div> </div>
<div class="f-ebox"> <div class="f-ebox">
{{ form.password_confirm.label }} {{ form.password_confirm.label }}
{{ form.password_confirm(autocomplete="new-password") }} {{ form.password_confirm }}
</div> </div>
<div class="f-bbox"> <div class="f-bbox">
{%- call form_button("passwd", form.action_reset, class="primary") -%}{%- endcall -%} {%- call form_button("passwd", form.action_reset, class="primary") -%}{%- endcall -%}
</div> </div>
</div></form> </div></form>
<script type="text/javascript">
var onload = function() {
apply_qr_code(document.getElementById("qr-uri"));
};
</script>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "invite.html" %} {% extends "invite.html" %}
{% set body_id = "invite" %} {% set body_id = "invite" %}
{% from "library.j2" import form_button, clipboard_button, render_errors %} {% from "library.j2" import form_button, clipboard_button %}
{% block head_lead %} {% block head_lead %}
<title>{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %}</title> <title>{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %}</title>
{%- include "copy-snippet.html" -%} {%- include "copy-snippet.html" -%}
@@ -15,47 +15,6 @@
{% trans %}Copy address{% endtrans %} {% trans %}Copy address{% endtrans %}
{%- endcall -%} {%- endcall -%}
<p>{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p> <p>{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p>
<p>{% trans login_url=url_for('main.login') %}You can now safely close this page, or log in to the web portal to <a href="{{ login_url }}">manage your account</a>.{% endtrans %}</p> <p>{% trans %}You can now safely close this page.{% endtrans %}</p>
{% 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> </div>
{% endblock %} {% endblock %}

View File

@@ -17,13 +17,6 @@
{%- else -%} {%- else -%}
<p>{% trans site_name=config["SITE_NAME"] %}You have been invited to chat on {{ site_name }} using Snikket, a secure, privacy-friendly chat app.{% endtrans %}</p> <p>{% trans site_name=config["SITE_NAME"] %}You have been invited to chat on {{ site_name }} using Snikket, a secure, privacy-friendly chat app.{% endtrans %}</p>
{%- endif -%} {%- endif -%}
{%- if config["TOS_URI"] and config["PRIVACY_URI"] -%}
<p>
{% trans site_name=config["SITE_NAME"], tos_uri=config["TOS_URI"], privacy_uri=config["PRIVACY_URI"] %}By continuing, you agree to the <a href="{{tos_uri}}">Terms of Service</a> and <a href="{{privacy_uri}}">Privacy Policy</a>.{% endtrans %}
</p>
{%- endif -%}
<h2>{% trans %}Get started{% endtrans %}</h2> <h2>{% trans %}Get started{% endtrans %}</h2>
{%- if apple_store_url -%} {%- if apple_store_url -%}
<p>{% trans %}Install the Snikket App on your Android or iOS device.{% endtrans %}</p> <p>{% trans %}Install the Snikket App on your Android or iOS device.{% endtrans %}</p>
@@ -63,7 +56,29 @@
{%- endcall -%} {%- endcall -%}
</header> </header>
<p>{% trans %}You can transfer this invite to your mobile device by scanning a code with your camera. You can use either a QR scanner app or the Snikket app itself.{% endtrans %}</p> <p>{% trans %}You can transfer this invite to your mobile device by scanning a code with your camera. You can use either a QR scanner app or the Snikket app itself.{% endtrans %}</p>
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True, _scheme="https") }}" class="qr"></div> <div class="tabbox">
{#- -#}
<nav class="tabs" role="tablist">
{#- -#}
<a href="#qr-info-url" class="active" role="tab" aria-controls="qr-info-url" aria-selected="true" onclick="select_tab(this); return false;">{% trans %}Using a QR code scanner{% endtrans %}</a>
{#- -#}
<a href="#qr-info-uri" role="tab" aria-controls="qr-info-uri" aria-selected="false" onclick="select_tab(this); return false;">{% trans %}Using the Snikket app{% endtrans %}</a>
{#- -#}
</nav>
{#- -#}
<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>
{#- -#}
<div id="qr-info-uri" class="tab-pane">
<img class="float-right" id="tutorial-scan" aria-hidden="true" alt="" src="{{ url_for("static", filename="img/tutorial-scan.png") }}">
<p>{% trans %}Install the Snikket app on your mobile device, open it, and tap the 'Scan' button at the top.{% endtrans %}</p>
<p>{% trans %}Your camera will turn on. Point it at the square code below until it is within the highlighted square on your screen, and wait until the app recognises it.{% endtrans %}</p>
<div id="qr-uri" data-qrdata="{{ invite.xmpp_uri }}" class="qr"></div>
</div>
{#- -#}
</div>
{#- -#} {#- -#}
{%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="primary") -%} {%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="primary") -%}
{% trans %}Close{% endtrans %} {% trans %}Close{% endtrans %}
@@ -134,6 +149,7 @@
var onload = function() { var onload = function() {
apply_qr_code(document.getElementById("qr-invite-page")); apply_qr_code(document.getElementById("qr-invite-page"));
apply_qr_code(document.getElementById("qr-uri"));
var popover_as = document.getElementsByClassName("popover"); var popover_as = document.getElementsByClassName("popover");
for (var i = 0; i < popover_as.length; ++i) { for (var i = 0; i < popover_as.length; ++i) {
var a = popover_as[i]; var a = popover_as[i];

View File

@@ -10,29 +10,6 @@
{%- endif -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
{% macro render_user(user, caller=None) -%}
<div class="username-with-avatar">
<div class="avatar-container">
{%- call avatar(user.localpart+"@"+config["SNIKKET_DOMAIN"], user.avatar_info[0].hash if user.avatar_info | length > 0 else None ) %}{% endcall -%}
{%- if user.has_admin_role -%}
<div class="user-badge-icon">
<span class="with-tooltip above" data-tooltip="{% trans %}The user is an administrator.{% endtrans %}">{% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %}</span>
</div>
{%- elif user.has_restricted_role -%}
<div class="user-badge-icon">
<span class="with-tooltip above" data-tooltip="{% trans %}The user is restricted.{% endtrans %}">{% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %}</span>
</div>
{%- endif -%}
</div>
<div class="user-info-container">
<div class="user-localpart">{{- user.localpart -}}</div>
{%- if user.display_name %}
<div class="user-display-name">{{- user.display_name -}}</div>
{%- endif %}
</div>
</div>
{%- endmacro -%}
{% macro showuri(uri, caller=None, id_=None) %} {% macro showuri(uri, caller=None, id_=None) %}
{%- if uri is none -%} {%- if uri is none -%}
<em>—</em> <em>—</em>

View File

@@ -1,39 +0,0 @@
{% extends "base.html" %}
{% from "library.j2" import standard_button %}
{% block head_lead %}
<title>{% trans %}Policies{% endtrans %} - {{ config["SITE_NAME"] }}</title>
{% endblock %}
{% block body %}
<main>
<div class="box el-2">
<h1>{{ config["SITE_NAME"] }}</h1>
<h2>{% trans %}Policies{% endtrans %}</h2>
{% if config["TOS_URI"] or config["PRIVACY_URI"] -%}
<p>{% trans %}Use of this service is subject to the following policies:{% endtrans %}</p>
<ul>
{%- if config["TOS_URI"] %}
<li><a href="{{ config["TOS_URI"] }}">{% trans %}Terms of Service{% endtrans %}</a></li>
{%- endif %}
{%- if config["PRIVACY_URI"] %}
<li><a href="{{ config["PRIVACY_URI"] }}">{% trans %}Privacy Policy{% endtrans %}</a></li>
{%- endif %}
</ul>
{%- else -%}
<p>{% trans %}Please contact the administrator of this instance if you have questions about policies.{% endtrans %}</p>
{% endif -%}
<p>{% trans url="https://snikket.org/app/privacy/" %}Use of the Snikket apps is subject to the <a href="{{url}}">Snikket Apps Privacy Policy</a>.{% endtrans %}</p>
{%- if config["ABUSE_EMAIL"] %}
<p>{% trans email=config["ABUSE_EMAIL"], domain=config["SNIKKET_DOMAIN"] %}To report policy violations or other abuse from this service, please send an email to {{email}}. Specify the domain name of this instance ({{domain}}) and include details of the incident(s).{% endtrans %}</p>
{%- endif %}
<p>
{%- call standard_button("back", url_for("index"), class="primary") -%}
{% trans %}Back to the main page{% endtrans %}
{%- endcall -%}
</p>
</div>
</main>
{% endblock %}

View File

@@ -1,16 +0,0 @@
# {{ config["SNIKKET_DOMAIN"] }} is running open-source software
# from the Snikket project: https://snikket.org/
{% if config["SECURITY_EMAIL"] -%}
# Security issues related to this service should be addressed to the
# following security contact:
Contact: mailto:{{ config["SECURITY_EMAIL"] }}
{% else -%}
# This service does not have a public security contact. You might find
# more information about the service at the following link:
Contact: https://{{ config["SNIKKET_DOMAIN"] }}/policies/
{%- endif %}
# Please report software defects to the project developers, per the
# instructions at the following link:
Contact: https://snikket.org/security/

View File

@@ -30,7 +30,6 @@
<div> <div>
<div>{% call standard_button("edit", url_for(".profile"), class="primary") %}{% trans %}Edit profile{% endtrans %}{% endcall %}</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("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> </div>
{#- -#} {#- -#}
</li> </li>

View File

@@ -1,24 +0,0 @@
{% 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">
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
<form method="POST">
{{ form.csrf_token }}
{%- call form_button("download", form.action_export, class="primary") %}{% endcall -%}
</form>
</div>
</li>
</ul>
</nav>
{% endblock %}

View File

@@ -9,15 +9,15 @@
{%- endcall -%} {%- endcall -%}
<div class="f-ebox"> <div class="f-ebox">
{{ form.current_password.label(class="required") }} {{ form.current_password.label(class="required") }}
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else ""), autocomplete="current-password") }} {{ form.current_password(class=("has-error" if form.current_password.name in form.errors else "")) }}
</div> </div>
<div class="f-ebox"> <div class="f-ebox">
{{ form.new_password.label(class="required") }} {{ form.new_password.label(class="required") }}
{{ form.new_password(autocomplete="new-password") }} {{ form.new_password }}
</div> </div>
<div class="f-ebox"> <div class="f-ebox">
{{ form.new_password_confirm.label(class="required") }} {{ form.new_password_confirm.label(class="required") }}
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else ""), autocomplete="new-password") }} {{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else "")) }}
</div> </div>
<div class="box warning"> <div class="box warning">
<header>{% trans %}Warning{% endtrans %}</header> <header>{% trans %}Warning{% endtrans %}</header>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,9 @@
import asyncio import asyncio
import typing import typing
import urllib
import quart.flask_patch import quart.flask_patch
from quart import ( from quart import (
Blueprint, Blueprint,
Response,
render_template, render_template,
request, request,
redirect, redirect,
@@ -13,7 +11,7 @@ from quart import (
flash, flash,
current_app, current_app,
) )
import werkzeug.exceptions import quart.exceptions
import wtforms import wtforms
@@ -59,7 +57,7 @@ _ACCESS_MODEL_CHOICES = [
class ProfileForm(BaseForm): class ProfileForm(BaseForm):
nickname = wtforms.StringField( nickname = wtforms.TextField(
_l("Display name"), _l("Display name"),
) )
@@ -77,16 +75,6 @@ class ProfileForm(BaseForm):
) )
class ImportAccountDataForm(BaseForm):
account_data_file = wtforms.FileField(
_l("Account data")
)
action_upload = wtforms.SubmitField(
_l("Upload"),
)
@bp.route("/") @bp.route("/")
@client.require_session() @client.require_session()
async def index() -> str: async def index() -> str:
@@ -96,7 +84,7 @@ async def index() -> str:
@bp.route('/passwd', methods=["GET", "POST"]) @bp.route('/passwd', methods=["GET", "POST"])
@client.require_session() @client.require_session()
async def change_pw() -> typing.Union[str, werkzeug.Response]: async def change_pw() -> typing.Union[str, quart.Response]:
form = ChangePasswordForm() form = ChangePasswordForm()
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -104,8 +92,8 @@ async def change_pw() -> typing.Union[str, werkzeug.Response]:
form.current_password.data, form.current_password.data,
form.new_password.data, form.new_password.data,
) )
except (werkzeug.exceptions.Unauthorized, except (quart.exceptions.Unauthorized,
werkzeug.exceptions.Forbidden): quart.exceptions.Forbidden):
# server refused current password, set an appropriate error # server refused current password, set an appropriate error
form.current_password.errors.append( form.current_password.errors.append(
_("Incorrect password."), _("Incorrect password."),
@@ -128,7 +116,7 @@ EAVATARTOOBIG = _l(
@bp.route("/profile", methods=["GET", "POST"]) @bp.route("/profile", methods=["GET", "POST"])
@client.require_session() @client.require_session()
async def profile() -> typing.Union[str, werkzeug.Response]: async def profile() -> typing.Union[str, quart.Response]:
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"] max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
form = ProfileForm() form = ProfileForm()
@@ -150,6 +138,7 @@ async def profile() -> typing.Union[str, werkzeug.Response]:
mimetype = file_info.mimetype mimetype = file_info.mimetype
data = file_info.stream.read() data = file_info.stream.read()
if len(data) > max_avatar_size: if len(data) > max_avatar_size:
print(len(data), max_avatar_size)
form.avatar.errors.append(EAVATARTOOBIG) form.avatar.errors.append(EAVATARTOOBIG)
ok = False ok = False
elif len(data) > 0: elif len(data) > 0:
@@ -179,49 +168,9 @@ async def profile() -> typing.Union[str, werkzeug.Response]:
avatar_too_big_warning=EAVATARTOOBIG) 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')
)
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"]) @bp.route("/logout", methods=["GET", "POST"])
@client.require_session() @client.require_session()
async def logout() -> typing.Union[werkzeug.Response, str]: async def logout() -> typing.Union[quart.Response, str]:
form = LogoutForm() form = LogoutForm()
if form.validate_on_submit(): if form.validate_on_submit():
await client.logout() await client.logout()

View File

@@ -4,7 +4,7 @@ import typing
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from quart import abort from quart import abort
import werkzeug.exceptions import quart.exceptions
TAG_XMPP_ERROR = "error" TAG_XMPP_ERROR = "error"
@@ -207,7 +207,7 @@ def make_avatar_metadata_set_request(
item, item,
"metadata", xmlns=NS_USER_AVATAR_METADATA) "metadata", xmlns=NS_USER_AVATAR_METADATA)
attr: typing.Dict[str, str] = { attr: typing.MutableMapping[str, str] = {
"id": id_, "id": id_,
"bytes": str(size), "bytes": str(size),
"type": mimetype, "type": mimetype,
@@ -217,12 +217,7 @@ def make_avatar_metadata_set_request(
if height is not None: if height is not None:
attr["height"] = str(height) attr["height"] = str(height)
ET.SubElement( ET.SubElement(metadata_wrap, "info", xmlns=NS_USER_AVATAR_METADATA, **attr)
metadata_wrap,
"info",
xmlns=NS_USER_AVATAR_METADATA,
**attr, # type: ignore
)
return req return req
@@ -239,7 +234,7 @@ def extract_pubsub_item_get_reply(
) -> typing.Optional[ET.Element]: ) -> typing.Optional[ET.Element]:
try: try:
pubsub = extract_iq_reply(iq_tree, TAG_PUBSUB) pubsub = extract_iq_reply(iq_tree, TAG_PUBSUB)
except werkzeug.exceptions.NotFound: except quart.exceptions.NotFound:
return None return None
if pubsub is None: if pubsub is None:

View File

@@ -6,21 +6,13 @@ action/logout:logout
action/login:login action/login:login
action/exit_to_app:exit_to_app action/exit_to_app:exit_to_app
action/lock:lock action/lock:lock
action/lock_open:lock_open
action/restore_from_trash:restore_from_trash
communication/import_export:import_export
communication/qr_code:qrcode communication/qr_code:qrcode
communication/vpn_key:passwd communication/vpn_key:passwd
communication/rss_feed:broadcast
content/add_circle_outline:add content/add_circle_outline:add
content/add_link:create_link content/add_link:create_link
content/remove_circle_outline:remove content/remove_circle_outline:remove
content/content_copy:copy content/content_copy:copy
content/link_off:remove_link content/link_off:remove_link
content/send:send
file/file_download:download
file/file_upload:upload
file/folder:folder
navigation/arrow_back:back navigation/arrow_back:back
navigation/arrow_forward:forward navigation/arrow_forward:forward
navigation/cancel:cancel navigation/cancel:cancel
@@ -34,4 +26,3 @@ navigation/close:close
image/edit:edit image/edit:edit
action/admin_panel_settings:admin action/admin_panel_settings:admin
content/link:link content/link:link
content/insights:insights

6
tools/import-icons.sh Executable file → Normal file
View File

@@ -9,9 +9,9 @@ set -euo pipefail
# FLAVOR one of '', 'round', 'sharp', 'outlined', 'twoshade' # FLAVOR one of '', 'round', 'sharp', 'outlined', 'twoshade'
# SVGOUT path to the newly created SVG file # SVGOUT path to the newly created SVG file
root="$1/src" root="$1/src"
iconlist_file="${2-tools/icons.list}" iconlist_file="$2"
flavor="${3-round}" flavor="$3"
output_file="${4-snikket_web/static/img/icons.svg}" output_file="$4"
printf '<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n<defs>\n' > "$output_file" printf '<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n<defs>\n' > "$output_file"
printf '<!-- These icons are sourced from Googles Material Icons set,\nlicensed under the terms of the Apache 2.0 License -->\n' >> "$output_file" printf '<!-- These icons are sourced from Googles Material Icons set,\nlicensed under the terms of the Apache 2.0 License -->\n' >> "$output_file"