Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da45395c2 | ||
|
|
7dde3a1128 | ||
|
|
934976c114 |
28
.github/workflows/main.yaml
vendored
@@ -27,7 +27,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
pip install mypy
|
||||
pip install -r requirements.txt
|
||||
pip install -r build-requirements.txt
|
||||
- name: Typecheck
|
||||
run: |
|
||||
python -m mypy --config mypy.ini -p snikket_web
|
||||
@@ -45,33 +44,10 @@ jobs:
|
||||
- name: Install
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pip install flake8 flake8-print
|
||||
pip install flake8
|
||||
- name: Linting
|
||||
run: |
|
||||
make flake8
|
||||
|
||||
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;/^"Generated-By: /d' snikket_web/translations/messages.pot
|
||||
git add snikket_web/translations/messages.pot
|
||||
make extract_translations
|
||||
sed -ri '/^"POT-Creation-Date: /d;/^"Generated-By: /d' snikket_web/translations/messages.pot
|
||||
git diff --exit-code --color -- snikket_web/translations/messages.pot
|
||||
|
||||
python -m flake8 snikket_web
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
60
Dockerfile
@@ -1,53 +1,41 @@
|
||||
FROM debian:bookworm-slim AS build
|
||||
FROM debian:buster-slim
|
||||
|
||||
RUN set -eu; \
|
||||
export DEBIAN_FRONTEND=noninteractive ; \
|
||||
apt-get update ; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-mypy python3-dotenv python3-toml python3-babel python3-distutils \
|
||||
sassc make;
|
||||
ARG BUILD_SERIES=dev
|
||||
ARG BUILD_ID=0
|
||||
|
||||
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
|
||||
COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt
|
||||
COPY Makefile /opt/snikket-web-portal/Makefile
|
||||
COPY snikket_web/ /opt/snikket-web-portal/snikket_web
|
||||
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
|
||||
|
||||
WORKDIR /opt/snikket-web-portal
|
||||
|
||||
RUN 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; \
|
||||
export DEBIAN_FRONTEND=noninteractive ; \
|
||||
apt-get update ; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
netcat-traditional python3 python3-setuptools python3-pip \
|
||||
python3-aiohttp python3-email-validator python3-flask-babel \
|
||||
python3-flaskext.wtf python3-hsluv python3-hypercorn \
|
||||
python3-quart python3-typing-extensions python3-wtforms ; \
|
||||
pip3 install --break-system-packages environ-config ; \
|
||||
apt-get remove -y --purge python3-pip python3-setuptools; \
|
||||
apt-get clean ; rm -rf /var/lib/apt/lists; \
|
||||
rm -rf /root/.cache;
|
||||
python3 python3-pip python3-setuptools python3-wheel \
|
||||
libpython3-dev \
|
||||
make build-essential \
|
||||
netcat \
|
||||
; \
|
||||
pip3 install -r requirements.txt; \
|
||||
pip3 install -r build-requirements.txt; \
|
||||
make; \
|
||||
pip3 uninstall -yr build-requirements.txt; \
|
||||
apt-get remove -y build-essential make libpython3-dev; \
|
||||
apt-get autoremove -y; \
|
||||
pip3 install hypercorn; \
|
||||
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}
|
||||
|
||||
COPY --from=build /opt/snikket-web-portal/snikket_web/ /opt/snikket-web-portal/snikket_web
|
||||
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
|
||||
|
||||
RUN echo "$BUILD_SERIES $BUILD_ID" > /opt/snikket-web-portal/.app_version
|
||||
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||
|
||||
22
Makefile
@@ -5,10 +5,8 @@ generated_css_files = $(patsubst snikket_web/scss/%.scss,snikket_web/static/css/
|
||||
translation_basepath = snikket_web/translations
|
||||
pot_file = $(translation_basepath)/messages.pot
|
||||
|
||||
black_formatted_py = snikket_web/prosodyclient.py
|
||||
|
||||
PYTHON3 ?= python3
|
||||
SCSSC ?= sassc --load-path snikket_web/scss/
|
||||
SCSSC ?= $(PYTHON3) -m scss --load-path snikket_web/scss/
|
||||
|
||||
all: build_css compile_translations
|
||||
|
||||
@@ -16,7 +14,7 @@ build_css: $(generated_css_files)
|
||||
|
||||
$(generated_css_files): snikket_web/static/css/%.css: snikket_web/scss/%.scss $(scss_files) $(scss_includes)
|
||||
mkdir -p snikket_web/static/css/
|
||||
$(SCSSC) "$<" "$@"
|
||||
$(SCSSC) -o "$@" "$<"
|
||||
|
||||
clean:
|
||||
rm -f $(generated_css_files)
|
||||
@@ -36,20 +34,4 @@ compile_translations:
|
||||
-pybabel compile -d $(translation_basepath)
|
||||
|
||||
|
||||
.PHONY: lint
|
||||
lint: format flake8
|
||||
|
||||
.PHONY: format
|
||||
format:
|
||||
$(PYTHON3) -m black $(black_formatted_py)
|
||||
|
||||
.PHONY: flake8
|
||||
flake8:
|
||||
$(PYTHON3) -m flake8 --exclude=$(subst $(space),$(comma),$(strip $(black_formatted_py))) snikket_web
|
||||
$(PYTHON3) -m flake8 --ignore=E501,W503 $(black_formatted_py)
|
||||
|
||||
.PHONY: mypy
|
||||
mypy:
|
||||
$(PYTHON3) -m mypy --python-version 3.11 snikket_web
|
||||
|
||||
.PHONY: build_css clean update_translations compile_translations extract_translations force_update_translations
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
## Development quickstart
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[python: snikket_web/**.py]
|
||||
[jinja2: snikket_web/templates/**.html]
|
||||
[jinja2: snikket_web/templates/**.j2]
|
||||
extensions=jinja2.ext.autoescape,jinja2.ext.with_
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pyscss~=1.3
|
||||
mypy
|
||||
python-dotenv~=0.15
|
||||
types-toml
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
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_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()'
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
aiohttp~=3.8,<3.9
|
||||
quart~=0.18,<0.19
|
||||
flask-wtf~=1.1,<1.2
|
||||
hsluv~=5.0
|
||||
flask-babel~=2.0,<3
|
||||
email-validator~=1.3
|
||||
aiohttp~=3.6
|
||||
quart~=0.11,<0.15
|
||||
flask-wtf~=0.14
|
||||
hsluv~=0.0.2
|
||||
flask-babel~=1.0
|
||||
email-validator~=1.1
|
||||
environ-config~=20.0
|
||||
wtforms~=3.0,<4
|
||||
typing-extensions
|
||||
werkzeug~=2.2,<3
|
||||
|
||||
@@ -18,12 +18,10 @@ from quart import (
|
||||
jsonify,
|
||||
)
|
||||
|
||||
import werkzeug.exceptions
|
||||
|
||||
import environ
|
||||
|
||||
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]:
|
||||
@@ -42,7 +40,7 @@ async def proc() -> typing.Dict[str, typing.Any]:
|
||||
|
||||
try:
|
||||
user_info = await infra.client.get_user_info()
|
||||
except (aiohttp.ClientError, werkzeug.exceptions.HTTPException):
|
||||
except (aiohttp.ClientError, quart.exceptions.HTTPException):
|
||||
user_info = {}
|
||||
|
||||
return {
|
||||
@@ -107,16 +105,16 @@ async def backend_error_handler(exc: Exception) -> quart.Response:
|
||||
|
||||
|
||||
async def generic_http_error(
|
||||
exc: werkzeug.exceptions.HTTPException,
|
||||
exc: quart.exceptions.HTTPException,
|
||||
) -> quart.Response:
|
||||
return quart.Response(
|
||||
await render_template(
|
||||
"generic_http_error.html",
|
||||
status=exc.code,
|
||||
status=exc.status_code,
|
||||
description=exc.description,
|
||||
name=exc.name,
|
||||
),
|
||||
status=exc.code,
|
||||
status=exc.status_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,35 +145,21 @@ class AppConfig:
|
||||
site_name = environ.var("")
|
||||
avatar_cache_ttl = environ.var(1800, converter=int)
|
||||
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",
|
||||
"de",
|
||||
"en",
|
||||
"fr",
|
||||
"id",
|
||||
"it",
|
||||
"pl",
|
||||
"ru",
|
||||
"sv",
|
||||
"uk",
|
||||
"zh_Hans_CN",
|
||||
], converter=autosplit)
|
||||
apple_store_url = environ.var(
|
||||
"https://apps.apple.com/us/app/snikket/id1545164189",
|
||||
)
|
||||
apple_store_url = environ.var("")
|
||||
# Default limit of 1 MiB is what was discovered to be the effective limit
|
||||
# in #67, hence we set that here for now.
|
||||
# Future versions may change this default, and the standard deployment
|
||||
# tools may also very well override it.
|
||||
max_avatar_size = environ.var(1024*1024, converter=int)
|
||||
show_metrics = environ.bool_var(True)
|
||||
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)))
|
||||
@@ -188,7 +172,7 @@ def create_app() -> quart.Quart:
|
||||
pass
|
||||
else:
|
||||
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():
|
||||
if not name:
|
||||
continue
|
||||
@@ -207,30 +191,23 @@ def create_app() -> quart.Quart:
|
||||
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
|
||||
app.config["APPLE_STORE_URL"] = config.apple_store_url
|
||||
app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size
|
||||
app.config["SHOW_METRICS"] = config.show_metrics
|
||||
app.config["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.config["SESSION_COOKIE_SECURE"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
|
||||
app.context_processor(proc)
|
||||
app.register_error_handler(
|
||||
aiohttp.ClientConnectorError,
|
||||
backend_error_handler,
|
||||
backend_error_handler, # type:ignore
|
||||
)
|
||||
app.register_error_handler(
|
||||
werkzeug.exceptions.HTTPException,
|
||||
quart.exceptions.HTTPException,
|
||||
generic_http_error, # type:ignore
|
||||
)
|
||||
app.register_error_handler(
|
||||
Exception,
|
||||
generic_error_handler,
|
||||
generic_error_handler, # type:ignore
|
||||
)
|
||||
|
||||
@app.route("/")
|
||||
async def index() -> werkzeug.Response:
|
||||
async def index() -> quart.Response:
|
||||
if infra.client.has_session:
|
||||
return redirect(url_for('user.index'))
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
version = "(unknown)"
|
||||
|
||||
if os.path.exists(".app_version"):
|
||||
with open(".app_version") as f:
|
||||
version = f.read().strip()
|
||||
elif os.path.exists(".git"):
|
||||
try:
|
||||
version = subprocess.check_output([
|
||||
"git", "describe", "--always"
|
||||
]).strip().decode("utf8")
|
||||
except OSError:
|
||||
version = "dev (unknown)"
|
||||
version_info = (0, 2, 2, None)
|
||||
version = (
|
||||
".".join(map(str, version_info[:3])) +
|
||||
(f"-{version_info[3]}" if version_info[3] else "")
|
||||
)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import json
|
||||
import resource
|
||||
import time
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
|
||||
import werkzeug.exceptions
|
||||
|
||||
import quart.flask_patch
|
||||
|
||||
import wtforms
|
||||
import wtforms.fields.html5
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
@@ -21,12 +18,11 @@ from quart import (
|
||||
request,
|
||||
abort,
|
||||
flash,
|
||||
current_app,
|
||||
)
|
||||
|
||||
from flask_babel import lazy_gettext as _l, _
|
||||
|
||||
from . import prosodyclient, _version
|
||||
from . import prosodyclient
|
||||
from .infra import client, circle_name, BaseForm
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@@ -35,11 +31,7 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@bp.route("/")
|
||||
@client.require_admin_session()
|
||||
async def index() -> str:
|
||||
show_metrics = current_app.config["SHOW_METRICS"]
|
||||
return await render_template(
|
||||
"admin_home.html",
|
||||
show_metrics=show_metrics,
|
||||
)
|
||||
return await render_template("admin_home.html")
|
||||
|
||||
|
||||
class PasswordResetLinkPost(BaseForm):
|
||||
@@ -64,6 +56,9 @@ async def users() -> str:
|
||||
)
|
||||
|
||||
|
||||
_LIMITED_ROLE_NAME = _("Limited")
|
||||
|
||||
|
||||
class EditUserForm(BaseForm):
|
||||
localpart = wtforms.StringField(
|
||||
_l("Login name"),
|
||||
@@ -76,25 +71,18 @@ class EditUserForm(BaseForm):
|
||||
role = wtforms.RadioField(
|
||||
_l("Access Level"),
|
||||
choices=[
|
||||
("prosody:restricted", _l("Limited")),
|
||||
("prosody:registered", _l("Normal user")),
|
||||
# NOTE: enable this only after something has been done which
|
||||
# actually enforces the described restrictions :).
|
||||
# ("prosody:restricted", _LIMITED_ROLE_NAME),
|
||||
("prosody:normal", _l("Normal user")),
|
||||
("prosody:admin", _l("Administrator")),
|
||||
],
|
||||
default="prosody:registered",
|
||||
)
|
||||
|
||||
action_save = wtforms.SubmitField(
|
||||
_l("Update user"),
|
||||
)
|
||||
|
||||
action_restore = wtforms.SubmitField(
|
||||
_l("Restore account"),
|
||||
)
|
||||
|
||||
action_enable = wtforms.SubmitField(
|
||||
_l("Unlock account"),
|
||||
)
|
||||
|
||||
action_create_reset = wtforms.SubmitField(
|
||||
_l("Create password reset link"),
|
||||
)
|
||||
@@ -102,7 +90,7 @@ class EditUserForm(BaseForm):
|
||||
|
||||
@bp.route("/user/<localpart>/", methods=["GET", "POST"])
|
||||
@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)
|
||||
|
||||
form = EditUserForm()
|
||||
@@ -121,44 +109,18 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
|
||||
".user_password_reset_link",
|
||||
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(
|
||||
localpart,
|
||||
display_name=form.display_name.data,
|
||||
role=form.role.data,
|
||||
roles=[form.role.data],
|
||||
)
|
||||
|
||||
await flash(
|
||||
_("User information updated."),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".users"))
|
||||
return redirect(url_for(".edit_user", localpart=localpart))
|
||||
|
||||
elif request.method == "GET":
|
||||
form.localpart.data = target_user_info.localpart
|
||||
@@ -166,7 +128,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
|
||||
if target_user_info.roles:
|
||||
form.role.data = target_user_info.roles[0]
|
||||
else:
|
||||
form.role.data = "prosody:registered"
|
||||
form.role.data = "prosody:normal"
|
||||
|
||||
return await render_template(
|
||||
"admin_edit_user.html",
|
||||
@@ -183,7 +145,7 @@ class DeleteUserForm(BaseForm):
|
||||
|
||||
@bp.route("/user/<localpart>/delete", methods=["GET", "POST"])
|
||||
@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)
|
||||
form = DeleteUserForm()
|
||||
if form.validate_on_submit():
|
||||
@@ -222,7 +184,7 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]:
|
||||
@client.require_admin_session()
|
||||
async def user_password_reset_link(
|
||||
id_: str,
|
||||
) -> typing.Union[str, werkzeug.Response]:
|
||||
) -> typing.Union[str, quart.Response]:
|
||||
invite_info = await client.get_invite_by_id(
|
||||
id_,
|
||||
)
|
||||
@@ -291,20 +253,6 @@ class InvitePost(BaseForm):
|
||||
default="account",
|
||||
)
|
||||
|
||||
role = wtforms.RadioField(
|
||||
_l("Access Level"),
|
||||
choices=[
|
||||
("prosody:restricted", _l("Limited")),
|
||||
("prosody:registered", _l("Normal user")),
|
||||
("prosody:admin", _l("Administrator")),
|
||||
],
|
||||
default="prosody:registered",
|
||||
)
|
||||
|
||||
note = wtforms.StringField(
|
||||
_l("Comment (optional)"),
|
||||
)
|
||||
|
||||
action_create_invite = wtforms.SubmitField(
|
||||
_l("New invitation link")
|
||||
)
|
||||
@@ -328,7 +276,7 @@ class InvitePost(BaseForm):
|
||||
|
||||
@bp.route("/invitations", methods=["GET", "POST"])
|
||||
@client.require_admin_session()
|
||||
async def invitations() -> typing.Union[str, werkzeug.Response]:
|
||||
async def invitations() -> typing.Union[str, quart.Response]:
|
||||
invites = sorted(
|
||||
(
|
||||
invite
|
||||
@@ -374,7 +322,7 @@ class InviteForm(BaseForm):
|
||||
|
||||
@bp.route("/invitation/-/new", methods=["POST"])
|
||||
@client.require_admin_session()
|
||||
async def create_invite() -> typing.Union[str, werkzeug.Response]:
|
||||
async def create_invite() -> typing.Union[str, quart.Response]:
|
||||
form = InvitePost()
|
||||
circles = await client.list_groups()
|
||||
form.circles.choices = [
|
||||
@@ -384,16 +332,12 @@ async def create_invite() -> typing.Union[str, werkzeug.Response]:
|
||||
if form.type_.data == "group":
|
||||
invite = await client.create_group_invite(
|
||||
group_ids=form.circles.data,
|
||||
role_names=[form.role.data],
|
||||
ttl=form.lifetime.data,
|
||||
note=form.note.data,
|
||||
)
|
||||
else:
|
||||
invite = await client.create_account_invite(
|
||||
group_ids=form.circles.data,
|
||||
role_names=[form.role.data],
|
||||
ttl=form.lifetime.data,
|
||||
note=form.note.data,
|
||||
)
|
||||
await flash(
|
||||
_("Invitation created"),
|
||||
@@ -406,7 +350,7 @@ async def create_invite() -> typing.Union[str, werkzeug.Response]:
|
||||
|
||||
@bp.route("/invitation/<id_>", methods=["GET", "POST"])
|
||||
@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:
|
||||
invite_info = await client.get_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
@@ -472,7 +416,7 @@ async def circles() -> str:
|
||||
|
||||
@bp.route("/circle/-/new", methods=["POST"])
|
||||
@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()
|
||||
if create_form.validate_on_submit():
|
||||
circle = await client.create_group(
|
||||
@@ -505,18 +449,20 @@ class EditCircleForm(BaseForm):
|
||||
_l("Update circle")
|
||||
)
|
||||
|
||||
action_delete = wtforms.SubmitField(
|
||||
_l("Delete circle permanently")
|
||||
)
|
||||
|
||||
action_remove_user = wtforms.StringField()
|
||||
|
||||
action_add_user = wtforms.SubmitField(
|
||||
_l("Add user")
|
||||
)
|
||||
|
||||
action_remove_group_chat = wtforms.StringField()
|
||||
|
||||
|
||||
@bp.route("/circle/<id_>", methods=["GET", "POST"])
|
||||
@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:
|
||||
try:
|
||||
circle = await client.get_group_by_id(
|
||||
@@ -532,21 +478,21 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
|
||||
return redirect(url_for(".circles"))
|
||||
raise
|
||||
|
||||
users = {
|
||||
user.localpart: user
|
||||
for user in await client.list_users()
|
||||
}
|
||||
users = sorted(
|
||||
await client.list_users(),
|
||||
key=lambda x: x.localpart
|
||||
)
|
||||
circle_members = [
|
||||
(localpart, users.get(localpart))
|
||||
for localpart in sorted(circle.members)
|
||||
user for user in users
|
||||
if user.localpart in circle.members
|
||||
]
|
||||
|
||||
form = EditCircleForm()
|
||||
form.user_to_add.choices = sorted(
|
||||
(localpart, localpart)
|
||||
for localpart in users.keys()
|
||||
if localpart not in circle.members
|
||||
)
|
||||
form.user_to_add.choices = [
|
||||
(user.localpart, user.localpart)
|
||||
for user in users
|
||||
if user.localpart not in circle.members
|
||||
]
|
||||
valid_users = [x[0] for x in form.user_to_add.choices]
|
||||
|
||||
invite_form = InvitePost()
|
||||
@@ -566,6 +512,13 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
|
||||
_("Circle data updated"),
|
||||
"success",
|
||||
)
|
||||
elif form.action_delete.data:
|
||||
await client.delete_group(id_)
|
||||
await flash(
|
||||
_("Circle deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".circles"))
|
||||
elif form.action_add_user.data:
|
||||
if form.user_to_add.data in valid_users:
|
||||
await client.add_group_member(
|
||||
@@ -585,269 +538,15 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
|
||||
_("User removed from circle"),
|
||||
"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_))
|
||||
else:
|
||||
print(form.errors)
|
||||
|
||||
return await render_template(
|
||||
"admin_edit_circle.html",
|
||||
target_circle=circle,
|
||||
form=form,
|
||||
circle_chats=circle.chats,
|
||||
circle_members=circle_members,
|
||||
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(
|
||||
_l("Message contents"),
|
||||
widget=wtforms.widgets.TextArea(),
|
||||
validators=[wtforms.validators.DataRequired()],
|
||||
)
|
||||
|
||||
online_only = wtforms.BooleanField(
|
||||
_l("Only send to online users"),
|
||||
)
|
||||
|
||||
action_post_all = wtforms.SubmitField(
|
||||
_l("Post to all users"),
|
||||
)
|
||||
|
||||
action_send_preview = wtforms.SubmitField(
|
||||
_l("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
|
||||
|
||||
try:
|
||||
metrics["users"] = prosody_metrics["users"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for k in list(metrics.keys()):
|
||||
if metrics[k] is None:
|
||||
# so that defaulting in jinja works
|
||||
del metrics[k]
|
||||
else:
|
||||
metrics = {}
|
||||
|
||||
return await render_template(
|
||||
"admin_system.html",
|
||||
metrics=metrics,
|
||||
version=_version.version,
|
||||
prosody_version=version,
|
||||
form=form,
|
||||
show_metrics=show_metrics,
|
||||
)
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import base64
|
||||
import itertools
|
||||
import math
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import quart.flask_patch # noqa:F401
|
||||
from quart import (
|
||||
current_app,
|
||||
request,
|
||||
g,
|
||||
)
|
||||
|
||||
import flask_babel
|
||||
import flask_wtf
|
||||
from flask_babel import lazy_gettext as _l
|
||||
import flask_babel as _
|
||||
from flask_babel import _
|
||||
|
||||
from . import prosodyclient
|
||||
|
||||
@@ -27,18 +22,8 @@ client.default_login_redirect = "main.login"
|
||||
babel = flask_babel.Babel()
|
||||
|
||||
|
||||
BYTE_UNIT_SCALE_MAP = [
|
||||
"B",
|
||||
"kiB",
|
||||
"MiB",
|
||||
"GiB",
|
||||
"TiB",
|
||||
]
|
||||
|
||||
|
||||
@babel.localeselector # type:ignore
|
||||
def selected_locale() -> str:
|
||||
g.language_header_accessed = True
|
||||
selected = request.accept_languages.best_match(
|
||||
current_app.config['LANGUAGES']
|
||||
) 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:
|
||||
if c.id_ == "default" and c.name == "default":
|
||||
return _l("Main")
|
||||
return _("Main")
|
||||
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:
|
||||
app.template_filter("repr")(repr)
|
||||
app.template_filter("format_datetime")(flask_babel.format_datetime)
|
||||
app.template_filter("format_date")(flask_babel.format_date)
|
||||
app.template_filter("format_time")(flask_babel.format_time)
|
||||
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
|
||||
app.template_filter("format_percent")(flask_babel.format_percent)
|
||||
app.template_filter("format_bytes")(format_bytes)
|
||||
app.template_filter("flatten")(flatten)
|
||||
app.template_filter("circle_name")(circle_name)
|
||||
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:
|
||||
|
||||
@@ -10,16 +10,13 @@ from quart import (
|
||||
current_app,
|
||||
render_template,
|
||||
redirect,
|
||||
request,
|
||||
url_for,
|
||||
session as http_session,
|
||||
)
|
||||
|
||||
import werkzeug
|
||||
|
||||
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
|
||||
|
||||
@@ -29,11 +26,6 @@ bp = Blueprint("invite", __name__)
|
||||
|
||||
INVITE_SESSION_JID = "invite-session-jid"
|
||||
|
||||
MAX_IMPORT_DATA_SIZE = 5*1024*1024 # 5MB
|
||||
SUPPORTED_IMPORT_TYPES = ["application/xml", "text/xml"]
|
||||
|
||||
EIMPORTTOOBIG = _l("The account data you tried to import is too large to"
|
||||
" upload. Please contact your Snikket operator.")
|
||||
|
||||
# https://play.google.com/store/apps/details?id=org.snikket.android&referrer={uri|urlescape}&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1
|
||||
|
||||
@@ -47,25 +39,15 @@ def apple_store_badge() -> str:
|
||||
return url_for("static", filename="img/apple/en.svg")
|
||||
|
||||
|
||||
def play_store_badge() -> str:
|
||||
locale = selected_locale()
|
||||
filename = "{}_badge_web_generic.png".format(locale)
|
||||
static_path = pathlib.Path(__file__).parent / "static" / "img" / "google"
|
||||
if (static_path / filename).exists():
|
||||
return url_for("static", filename="img/google/{}".format(filename))
|
||||
return url_for("static", filename="img/google/en_badge_web_generic.png")
|
||||
|
||||
|
||||
@bp.context_processor
|
||||
def context() -> typing.Dict[str, typing.Any]:
|
||||
def context() -> typing.Mapping[str, typing.Any]:
|
||||
return {
|
||||
"apple_store_badge": apple_store_badge,
|
||||
"play_store_badge": play_store_badge,
|
||||
}
|
||||
|
||||
|
||||
@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_))
|
||||
|
||||
|
||||
@@ -114,7 +96,7 @@ async def view(id_: str) -> typing.Union[quart.Response,
|
||||
return quart.Response(
|
||||
body,
|
||||
headers={
|
||||
"Link": "<{}>; rel=\"alternate\"".format(invite.xmpp_uri),
|
||||
"Link": "<{}> rel=\"alternate\"".format(invite.xmpp_uri),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -126,10 +108,6 @@ class RegisterForm(BaseForm):
|
||||
|
||||
password = wtforms.PasswordField(
|
||||
_l("Password"),
|
||||
validators=[
|
||||
wtforms.validators.InputRequired(),
|
||||
wtforms.validators.Length(min=10),
|
||||
],
|
||||
)
|
||||
|
||||
password_confirm = wtforms.PasswordField(
|
||||
@@ -147,7 +125,7 @@ class RegisterForm(BaseForm):
|
||||
|
||||
|
||||
@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:
|
||||
invite = await client.get_public_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
@@ -185,7 +163,6 @@ async def register(id_: str) -> typing.Union[str, werkzeug.Response]:
|
||||
raise
|
||||
else:
|
||||
http_session[INVITE_SESSION_JID] = jid
|
||||
await client.login(jid, form.password.data)
|
||||
return redirect(url_for(".success"))
|
||||
|
||||
return await render_template(
|
||||
@@ -198,10 +175,6 @@ async def register(id_: str) -> typing.Union[str, werkzeug.Response]:
|
||||
class ResetForm(BaseForm):
|
||||
password = wtforms.PasswordField(
|
||||
_l("Password"),
|
||||
validators=[
|
||||
wtforms.validators.InputRequired(),
|
||||
wtforms.validators.Length(min=10),
|
||||
],
|
||||
)
|
||||
|
||||
password_confirm = wtforms.PasswordField(
|
||||
@@ -219,7 +192,7 @@ class ResetForm(BaseForm):
|
||||
|
||||
|
||||
@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:
|
||||
invite = await client.get_public_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
@@ -259,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"])
|
||||
@client.require_session()
|
||||
async def success() -> str:
|
||||
form = DataImportForm()
|
||||
if form.validate_on_submit():
|
||||
ok = True
|
||||
file_info = (await request.files).get(form.account_data_file.name)
|
||||
if file_info is not None:
|
||||
mimetype = file_info.mimetype
|
||||
data = file_info.stream.read()
|
||||
if len(data) > MAX_IMPORT_DATA_SIZE:
|
||||
form.account_data_file.errors.append(EIMPORTTOOBIG)
|
||||
ok = False
|
||||
elif mimetype not in SUPPORTED_IMPORT_TYPES:
|
||||
form.account_data_file.errors.append(
|
||||
# not breaking the line here to avoid extract
|
||||
# translations failing (defensive)
|
||||
gettext("The account data you tried to import is in an unknown format. Please upload an XML file in XEP-0227 format (provided format: %(mimetype)s).", mimetype=mimetype), # noqa:E501
|
||||
)
|
||||
ok = False
|
||||
elif len(data) > 0:
|
||||
await client.import_account_data(data)
|
||||
|
||||
if ok:
|
||||
# Re-render success page, this time with no import option
|
||||
return await render_template(
|
||||
"invite_success.html",
|
||||
jid=http_session.get(INVITE_SESSION_JID, ""),
|
||||
migration_success=True,
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"invite_success.html",
|
||||
jid=http_session.get(INVITE_SESSION_JID, ""),
|
||||
migration_success=False,
|
||||
form=form,
|
||||
max_import_size=MAX_IMPORT_DATA_SIZE,
|
||||
import_too_big_warning_header=_l("Error"),
|
||||
import_too_big_warning=EIMPORTTOOBIG,
|
||||
)
|
||||
|
||||
|
||||
@@ -320,5 +249,5 @@ async def reset_success() -> str:
|
||||
|
||||
|
||||
@bp.route("/-")
|
||||
async def index() -> werkzeug.Response:
|
||||
async def index() -> quart.Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@@ -18,8 +18,6 @@ from quart import (
|
||||
flash,
|
||||
)
|
||||
|
||||
import werkzeug.exceptions
|
||||
|
||||
import babel
|
||||
import wtforms
|
||||
|
||||
@@ -34,7 +32,7 @@ bp = quart.Blueprint("main", __name__)
|
||||
|
||||
|
||||
class LoginForm(BaseForm):
|
||||
address = wtforms.StringField(
|
||||
address = wtforms.TextField(
|
||||
_l("Address"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
)
|
||||
@@ -50,7 +48,7 @@ class LoginForm(BaseForm):
|
||||
|
||||
|
||||
@bp.route("/-")
|
||||
async def index() -> werkzeug.Response:
|
||||
async def index() -> quart.Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@@ -58,7 +56,7 @@ ERR_CREDENTIALS_INVALID = _l("Invalid username or password.")
|
||||
|
||||
|
||||
@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()):
|
||||
return redirect(url_for('user.index'))
|
||||
|
||||
@@ -78,7 +76,7 @@ async def login() -> typing.Union[str, werkzeug.Response]:
|
||||
password = form.password.data
|
||||
try:
|
||||
await client.login(jid, password)
|
||||
except werkzeug.exceptions.Unauthorized:
|
||||
except quart.exceptions.Unauthorized:
|
||||
form.password.errors.append(ERR_CREDENTIALS_INVALID)
|
||||
else:
|
||||
await flash(
|
||||
@@ -93,30 +91,24 @@ async def login() -> typing.Union[str, werkzeug.Response]:
|
||||
@bp.route("/meta/about.html")
|
||||
async def about() -> str:
|
||||
version = None
|
||||
core_versions = {}
|
||||
extra_versions = {}
|
||||
|
||||
if current_app.debug or client.is_admin_session:
|
||||
version = _version.version
|
||||
try:
|
||||
core_versions["Prosody"] = await client.get_server_version()
|
||||
except werkzeug.exceptions.Unauthorized:
|
||||
core_versions["Prosody"] = "unknown"
|
||||
|
||||
if current_app.debug:
|
||||
extra_versions["Quart"] = quart.__version__
|
||||
extra_versions["aiohttp"] = aiohttp.__version__
|
||||
extra_versions["babel"] = babel.__version__
|
||||
extra_versions["wtforms"] = wtforms.__version__
|
||||
extra_versions["flask-wtf"] = flask_wtf.__version__
|
||||
try:
|
||||
extra_versions["Prosody"] = await client.get_server_version()
|
||||
except werkzeug.exceptions.Unauthorized:
|
||||
except quart.exceptions.Unauthorized:
|
||||
extra_versions["Prosody"] = "unknown"
|
||||
|
||||
return await render_template(
|
||||
"about.html",
|
||||
version=version,
|
||||
extra_versions=extra_versions,
|
||||
core_versions=core_versions,
|
||||
)
|
||||
|
||||
|
||||
@@ -173,42 +165,6 @@ async def avatar(from_: str, code: str) -> quart.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")
|
||||
async def health() -> Response:
|
||||
return Response("STATUS OK", content_type="text/plain")
|
||||
|
||||
@@ -259,13 +259,6 @@ div.form.layout-expanded {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset.descriptive-radio-selection {
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: $w-s2;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="radio"] + label, input[type="checkbox"] + label {
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
@@ -282,22 +275,22 @@ div.form.layout-expanded {
|
||||
}
|
||||
|
||||
@each $type in $text-entry-inputs {
|
||||
input[type=#{$type}] {
|
||||
input[type=$type] {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: $w-s4 solid $primary-500;
|
||||
margin-bottom: -$w-s4;
|
||||
}
|
||||
|
||||
input[type=#{$type}].has-error {
|
||||
input[type=$type].has-error {
|
||||
border-right: $w-s4 solid $alert-500;
|
||||
}
|
||||
|
||||
input[type=#{$type}]:hover {
|
||||
input[type=$type]:hover {
|
||||
border-bottom-color: $primary-700;
|
||||
}
|
||||
|
||||
input[type=#{$type}]:focus {
|
||||
input[type=$type]:focus {
|
||||
border-bottom-color: $primary-800;
|
||||
}
|
||||
}
|
||||
@@ -370,10 +363,6 @@ div.form.layout-expanded {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
.radio-button-ext {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
div.select-wrap {
|
||||
display: block;
|
||||
border-bottom: $w-s4 solid $primary-500;
|
||||
@@ -657,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 */
|
||||
|
||||
.box {
|
||||
@@ -719,7 +771,8 @@ input[type="submit"], button, .button {
|
||||
height: 1.5em;
|
||||
vertical-align: middle;
|
||||
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;
|
||||
|
||||
@@ -992,18 +1045,19 @@ div.profile-card {
|
||||
}
|
||||
}
|
||||
|
||||
/* clipboard and share buttons */
|
||||
/* clipboard button */
|
||||
|
||||
.copy-to-clipboard, .share-button {
|
||||
.copy-to-clipboard {
|
||||
cursor: pointer;
|
||||
font-style: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.no-copy .copy-to-clipboard, body.no-share .share-button {
|
||||
body.no-copy .copy-to-clipboard {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* magic */
|
||||
|
||||
pre.guru-meditation {
|
||||
@@ -1067,7 +1121,7 @@ pre.guru-meditation {
|
||||
}
|
||||
|
||||
@each $type in $text-entry-inputs {
|
||||
input[type=#{$type}] {
|
||||
input[type=$type] {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
@@ -1077,10 +1131,6 @@ pre.guru-meditation {
|
||||
}
|
||||
}
|
||||
|
||||
label, legend {
|
||||
color: $gray-800 !important;
|
||||
}
|
||||
|
||||
.box {
|
||||
background-color: black;
|
||||
border-color: $gray-800;
|
||||
@@ -1215,13 +1265,6 @@ pre.guru-meditation {
|
||||
p.form-desc.weak, p.field-desc.weak {
|
||||
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 */
|
||||
@@ -1272,53 +1315,3 @@ pre.guru-meditation {
|
||||
.with-tooltip:hover:before, .with-tooltip:hover:after {
|
||||
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;
|
||||
}
|
||||
|
||||
.user-display-name {
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.user-jid {
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,60 @@ img.fdroid {
|
||||
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 {
|
||||
margin: $w-l1 0;
|
||||
display: flex;
|
||||
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -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>
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z" />
|
||||
</symbol>
|
||||
<!-- from: 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 -->
|
||||
<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>
|
||||
@@ -67,12 +52,6 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
||||
</symbol>
|
||||
<!-- from: communication/rss_feed/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-broadcast" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<circle cx="6.18" cy="17.82" r="2.18" />
|
||||
<path d="M5.59 10.23c-.84-.14-1.59.55-1.59 1.4 0 .71.53 1.28 1.23 1.4 2.92.51 5.22 2.82 5.74 5.74.12.7.69 1.23 1.4 1.23.85 0 1.54-.75 1.41-1.59-.68-4.2-3.99-7.51-8.19-8.18zm-.03-5.71C4.73 4.43 4 5.1 4 5.93c0 .73.55 1.33 1.27 1.4 6.01.6 10.79 5.38 11.39 11.39.07.73.67 1.28 1.4 1.28.84 0 1.5-.73 1.42-1.56-.73-7.34-6.57-13.19-13.92-13.92z" />
|
||||
</symbol>
|
||||
<!-- from: content/add_circle_outline/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-add" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -98,26 +77,6 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M21.94 11.23C21.57 8.76 19.32 7 16.82 7h-2.87c-.52 0-.95.43-.95.95s.43.95.95.95h2.9c1.6 0 3.04 1.14 3.22 2.73.17 1.43-.64 2.69-1.85 3.22l1.4 1.4c1.63-1.02 2.64-2.91 2.32-5.02zM4.12 3.56c-.39-.39-1.02-.39-1.41 0s-.39 1.02 0 1.41l2.4 2.4c-1.94.8-3.27 2.77-3.09 5.04C2.23 15.05 4.59 17 7.23 17h2.82c.52 0 .95-.43.95-.95s-.43-.95-.95-.95H7.16c-1.63 0-3.1-1.19-3.25-2.82-.15-1.72 1.11-3.17 2.75-3.35l2.1 2.1c-.43.09-.76.46-.76.92v.1c0 .52.43.95.95.95h1.78L13 15.27V17h1.73l3.3 3.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L4.12 3.56zM16 11.95c0-.52-.43-.95-.95-.95h-.66l1.49 1.49c.07-.13.12-.28.12-.44v-.1z" />
|
||||
</symbol>
|
||||
<!-- from: content/send/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-send" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M3.4 20.4l17.45-7.48c.81-.35.81-1.49 0-1.84L3.4 3.6c-.66-.29-1.39.2-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
|
||||
</symbol>
|
||||
<!-- from: file/file_download/materialicons/24px.svg -->
|
||||
<symbol id="icon-download" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
|
||||
</symbol>
|
||||
<!-- from: file/file_upload/materialicons/24px.svg -->
|
||||
<symbol id="icon-upload" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z" />
|
||||
</symbol>
|
||||
<!-- from: file/folder/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-folder" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M10.59 4.59C10.21 4.21 9.7 4 9.17 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-1.41-1.41z" />
|
||||
</symbol>
|
||||
<!-- from: navigation/arrow_back/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-back" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -148,11 +107,6 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V18c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-1.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05.02.01.03.03.04.04 1.14.83 1.93 1.94 1.93 3.41V18c0 .35-.07.69-.18 1H22c.55 0 1-.45 1-1v-1.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||
</symbol>
|
||||
<!-- from: social/person/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-person" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v1c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-1c0-2.66-5.33-4-8-4z" />
|
||||
</symbol>
|
||||
<!-- from: social/group_add/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-create_group" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -188,14 +142,4 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M17 7h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c1.65 0 3 1.35 3 3s-1.35 3-3 3h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-9 5c0 .55.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1H9c-.55 0-1 .45-1 1zm2 3H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h3c.55 0 1-.45 1-1s-.45-1-1-1H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h3c.55 0 1-.45 1-1s-.45-1-1-1z" />
|
||||
</symbol>
|
||||
<!-- from: content/insights/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-insights" viewBox="0 0 24 24">
|
||||
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
|
||||
<g><g><path d="M21,8c-1.45,0-2.26,1.44-1.93,2.51l-3.55,3.56c-0.3-0.09-0.74-0.09-1.04,0l-2.55-2.55C12.27,10.45,11.46,9,10,9 c-1.45,0-2.27,1.44-1.93,2.52l-4.56,4.55C2.44,15.74,1,16.55,1,18c0,1.1,0.9,2,2,2c1.45,0,2.26-1.44,1.93-2.51l4.55-4.56 c0.3,0.09,0.74,0.09,1.04,0l2.55,2.55C12.73,16.55,13.54,18,15,18c1.45,0,2.27-1.44,1.93-2.52l3.56-3.55 C21.56,12.26,23,11.45,23,10C23,8.9,22.1,8,21,8z" /><polygon points="15,9 15.94,6.93 18,6 15.94,5.07 15,3 14.08,5.07 12,6 14.08,6.93" /><polygon points="3.5,11 4,9 6,8.5 4,8 3.5,6 3,8 1,8.5 3,9" /></g></g>
|
||||
</symbol>
|
||||
<!-- from: social/share/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-share" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z" />
|
||||
</symbol>
|
||||
</defs></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
@@ -6,32 +6,24 @@
|
||||
{% block body %}
|
||||
<main>
|
||||
<div class="box el-2">
|
||||
<h2>{% trans %}About this Service{% endtrans %}</h2>
|
||||
<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>
|
||||
<h1>{% trans %}About Snikket{% endtrans %}</h1>
|
||||
<p>{% trans snikket_url="https://snikket.org" %}To learn more about Snikket, visit the <a href="{{ snikket_url}}">Snikket website</a>.{% endtrans %}</p>
|
||||
|
||||
<p><a href="/policies/">{% trans %}View service policies{% endtrans %}</a>
|
||||
|
||||
<h2>{% trans %}About this Service{% endtrans %}</h2>
|
||||
<p>{% trans site_name=config["SITE_NAME"] %}This is the Snikket service <em>{{ site_name }}</em>.{% endtrans %}</p>
|
||||
<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 source_url="https://github.com/snikket-im/snikket-web-portal/" %}The source code of the web portal can be downloaded and viewed in <a href="{{ source_url }}">its GitHub repository</a>.{% endtrans %}</p>
|
||||
<p>{% trans source_url="https://material.io/resources/icons/", apache20_url="https://www.apache.org/licenses/LICENSE-2.0.txt" %}The icons used in the web portal are <a href="{{ source_url }}">Google’s Material Icons</a>, made available by Google under the terms of the <a href="{{ apache20_url }}">Apache 2.0 License</a>.{% endtrans %}</p>
|
||||
|
||||
<h3>{% trans %}Trademarks{% endtrans %}</h3>
|
||||
<p>{% trans trademarks_url="https://snikket.org/about/trademarks/" %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company. For more information about the trademarks, visit the <a href="{{ trademarks_url }}">Snikket Trademarks information page</a>.{% endtrans %}
|
||||
|
||||
<h3>{% trans %}Software Versions{% endtrans %}</h3>
|
||||
<pre>Domain: {{ config["SNIKKET_DOMAIN"] }}
|
||||
Web Portal{% if version %} ({{ version }}){% endif %}
|
||||
{%- if core_versions -%}
|
||||
{% for name, version in core_versions.items() %}
|
||||
{{ name }} ({{ version }}){% endfor %}
|
||||
{%- endif -%}
|
||||
<pre>Snikket Server
|
||||
Domain: {{ config["SNIKKET_DOMAIN"] }}
|
||||
Snikket Web Portal{% if version %} ({{ version }}){% endif %}
|
||||
{%- if extra_versions -%}
|
||||
{% for name, version in extra_versions.items() %}
|
||||
{{ name }} ({{ version }}){% endfor %}
|
||||
{%- endif -%}</pre>
|
||||
|
||||
<p>
|
||||
{%- call standard_button("back", url_for("index"), class="primary") -%}
|
||||
{% trans %}Back to the main page{% endtrans %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<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 %}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 -%}
|
||||
<form method="POST" action="{{ url_for(".create_invite") }}">
|
||||
{{- invite_form.csrf_token -}}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ target_circle.name }}</h1>
|
||||
{%- include "admin_create_circle_group_chat_form.html" -%}
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -1,57 +1,19 @@
|
||||
{% from "library.j2" import form_button,
|
||||
render_errors,
|
||||
access_level_description, access_level_icon,
|
||||
invite_type_description, invite_type_icon
|
||||
%}
|
||||
{% from "library.j2" import form_button, render_errors %}
|
||||
<form method="POST" action="{{ url_for(".create_invite") }}">
|
||||
{{- invite_form.csrf_token -}}
|
||||
<div class="form layout-expanded">
|
||||
<h2 class="form-title">{% trans %}Create new invitation{% endtrans %}</h2>
|
||||
<p class="form-descr weak">{% trans %}Create a new invitation link to invite more users to your Snikket service by clicking the button below.{% endtrans %}</p>
|
||||
|
||||
<!-- Invitation type -->
|
||||
<div class="f-ebox">
|
||||
<fieldset class="descriptive-radio-selection">{#- -#}
|
||||
<fieldset>{#- -#}
|
||||
<legend>{{ invite_form.type_.label.text }}</legend>
|
||||
<p>{% trans %}Choose whether this invitation link will allow more than one person to join.{% endtrans %}</p>
|
||||
|
||||
{%- for invite_type in invite_form.type_ -%}
|
||||
<div class="radio-button-ext">
|
||||
{{ invite_type }}<label for="{{ invite_type.id }}">
|
||||
{%- trans title=invite_type.label.text, icon=invite_type_icon(invite_type.data), description=invite_type_description(invite_type.data) -%}
|
||||
<span class="invite-type">{{ title }}{{ icon }}</span><p>{{ description }}</p>
|
||||
{%- endtrans -%}
|
||||
</label>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{{- invite_form.type_ -}}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Access level -->
|
||||
<div class="f-ebox">
|
||||
<fieldset class="descriptive-radio-selection">{#- -#}
|
||||
<legend>{{ invite_form.role.label.text }}</legend>
|
||||
<p>{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}</p>
|
||||
{%- for level in invite_form.role -%}
|
||||
<div class="radio-button-ext">
|
||||
{{ level }}<label for="{{ level.id }}">
|
||||
{%- trans title=level.label.text, icon=access_level_icon(level.data), description=access_level_description(level.data) -%}
|
||||
<span class="access-level">{{ title }}{{ icon }}</span><p>{{ description }}</p>
|
||||
{%- endtrans -%}
|
||||
</label>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Valid for -->
|
||||
<div class="f-ebox">
|
||||
{{ invite_form.lifetime.label }}
|
||||
<div class="select-wrap">{{ invite_form.lifetime }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite to circle -->
|
||||
<div class="f-ebox">
|
||||
{#
|
||||
NOTE: This is for when/if we ever support multi-group invites.
|
||||
@@ -65,13 +27,6 @@
|
||||
<div class="select-wrap">{{ invite_form.circles }}</div>
|
||||
{%- call render_errors(invite_form.circles) -%}{%- endcall -%}
|
||||
</div>
|
||||
|
||||
<!-- Comment -->
|
||||
<div class="f-ebox">
|
||||
{{ invite_form.note.label }}
|
||||
{{ invite_form.note }}
|
||||
</div>
|
||||
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("create_link", invite_form.action_create_invite, class="primary") %}{% endcall -%}
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon, render_user with context %}
|
||||
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
@@ -13,6 +13,13 @@
|
||||
<div class="box hint form layout-expanded">
|
||||
<header>{% trans %}This is your main circle{% endtrans %}</header>
|
||||
<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>
|
||||
{%- else -%}
|
||||
<div class="form layout-expanded">
|
||||
@@ -21,6 +28,17 @@
|
||||
{{ form.name.label }}
|
||||
{{ form.name }}
|
||||
</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">
|
||||
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
|
||||
{% trans %}Return to circle list{% endtrans %}
|
||||
@@ -30,60 +48,23 @@
|
||||
<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>
|
||||
<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>
|
||||
{%- 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>{% 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>
|
||||
<p>{% trans %}All members of the circle will see each other in their contact list.{% endtrans %}</p>
|
||||
|
||||
{%- if circle_members -%}
|
||||
<div class="el-2 elevated"><table>
|
||||
<thead>
|
||||
<th>{% trans %}Login name{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
<th>Login name</th>
|
||||
<th class="collapsible">Display name</th>
|
||||
<th>Actions</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for localpart, member in circle_members -%}
|
||||
{%- for member in circle_members -%}
|
||||
<tr>
|
||||
<td>
|
||||
{%- if member -%}
|
||||
{%- call render_user(member) -%}{%- endcall -%}
|
||||
{%- 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>{{ member.localpart }}</td>
|
||||
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
|
||||
<td class="nowrap">
|
||||
{%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}
|
||||
{% trans username=member.localpart %}Remove user {{ username }} from circle{% endtrans %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import showuri, form_button, standard_button, extract_circle_name, invite_type_name, invite_type_description %}
|
||||
{% from "library.j2" import showuri, form_button, standard_button, extract_circle_name, invite_type_description %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
@@ -13,10 +13,9 @@
|
||||
<dt>{% trans %}Valid until{% endtrans %}</dt>
|
||||
<dd>{{ invite.expires | format_date }}</dd>
|
||||
<dt><label for="link-field">{% trans %}Link{% endtrans %}</label></dt>
|
||||
<dd>{% call showuri(invite.landing_page, id_="link-field") %}{% trans %}Invitation to Snikket{% endtrans %}{% endcall %}</dd>
|
||||
<dd>{% call showuri(invite.landing_page, id_="link-field") %}{% endcall %}</dd>
|
||||
<dt>{% trans %}Invitation type{% endtrans %}</dt>
|
||||
{% set invite_type = invite.reusable and "group" or "account" %}
|
||||
<dd><span class="with-tooltip above" data-tooltip="{% call invite_type_description(invite_type) %}{% endcall %}">{% call invite_type_name(invite_type) %}{% endcall %}</span></dd>
|
||||
<dd>{% call invite_type_description(invite) %}{% endcall %}</dd>
|
||||
{%- set ngroups = invite.group_ids | length -%}
|
||||
{%- if ngroups > 1 -%}
|
||||
{#- not supported via the web UI, but we should still display it properly -#}
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import box, form_button, standard_button, icon, access_level_description, access_level_icon %}
|
||||
{% from "library.j2" import box, form_button, standard_button, icon %}
|
||||
{% macro access_level_description(role, caller=None) %}
|
||||
{%- if role == "prosody:restricted" -%}
|
||||
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
|
||||
{%- elif role == "prosody:normal" -%}
|
||||
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
|
||||
{%- elif role == "prosody:admin" -%}
|
||||
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro access_level_icon(role, caller=None) %}
|
||||
{%- if role == "prosody:restricted" -%}
|
||||
{% call icon("lock") %}{% endcall %}
|
||||
{%- elif role == "prosody:admin" -%}
|
||||
{% call icon("admin") %}{% endcall %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% block content %}
|
||||
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
|
||||
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
|
||||
{% 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>
|
||||
|
||||
<div class="f-ebox">
|
||||
{{ form.localpart.label }}
|
||||
{{ form.localpart(readonly="readonly") }}
|
||||
<p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="f-ebox">
|
||||
{{ form.display_name.label }}
|
||||
{{ form.display_name }}
|
||||
@@ -68,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 %}
|
||||
</p>
|
||||
<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>
|
||||
<h2 class="form-title">{% trans %}Debug information{% endtrans %}</h2>
|
||||
<p class="form-desc">
|
||||
{% trans %}In some cases, extended information about the user account and the connected devices is necessary to troubleshoot issues. The button below reveals this (sensitive) information.{% endtrans %}
|
||||
</p>
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="secondary") -%}
|
||||
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="primary") -%}
|
||||
{%- trans -%}Show debug information{%- endtrans -%}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
|
||||
@@ -31,18 +31,6 @@
|
||||
<div>{% call standard_button("link", url_for(".invitations"), class="primary") %}{% trans %}Manage invitations{% endtrans %}{% endcall %}</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
<li>
|
||||
<h2>{% trans %}System health{% endtrans %}</h2>
|
||||
{#- -#}
|
||||
{%- if show_metrics -%}
|
||||
<p>{% trans %}View the server status or send a broadcast message to all users.{% endtrans %}</p>
|
||||
{%- else -%}
|
||||
<p>{% trans %}Send a broadcast message to all users.{% endtrans %}</p>
|
||||
{%- endif -%}
|
||||
{#- -#}
|
||||
<div>{% call standard_button("insights", url_for(".system"), class="primary") %}{% trans %}Manage system{% endtrans %}{% endcall %}</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
<li>
|
||||
{#- -#}
|
||||
<p>{% trans %}Go back to your user's web portal page.{% endtrans %}</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import action_button, icon, clipboard_button, share_button, form_button, custom_form_button, extract_circle_name, invite_type_name, invite_type_description %}
|
||||
{% from "library.j2" import action_button, icon, clipboard_button, form_button, custom_form_button, extract_circle_name, invite_type_name, invite_type_description %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
@@ -18,18 +18,17 @@
|
||||
<col/>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}Expires{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Type{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Circle{% endtrans %}</th>
|
||||
<th>{% trans %}Expires{% endtrans %}</th>
|
||||
<th>{% trans %}Comment{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invite in invites %}
|
||||
{% set invite_type = invite.reusable and "group" or "account" %}
|
||||
<tr>
|
||||
<td class="collapsible"><span class="with-tooltip above" data-tooltip="{% call invite_type_description(invite_type) %}{% endcall %}">{% call invite_type_name(invite_type) %}{% endcall %}</span></td>
|
||||
<td>{{ (invite.expires - now) | format_timedelta(add_direction=True) }}</td>
|
||||
<td class="collapsible"><span class="with-tooltip above" data-tooltip="{% call invite_type_description(invite) %}{% endcall %}">{% call invite_type_name(invite) %}{% endcall %}</span></td>
|
||||
<td class="collapsible">
|
||||
{#- -#}
|
||||
<ul class="inline">
|
||||
@@ -39,8 +38,6 @@
|
||||
</ul>
|
||||
{#- -#}
|
||||
</td>
|
||||
<td>{{ (invite.expires - now) | format_timedelta(add_direction=True) }}</td>
|
||||
<td>{% if invite.note is not none %}{{ invite.note }}{% endif %}</td>
|
||||
<td class="nowrap">
|
||||
{%- call action_button("more", url_for(".edit_invite", id_=invite.id_), class="secondary") -%}
|
||||
{% trans %}Show invite details{% endtrans %}
|
||||
@@ -48,9 +45,6 @@
|
||||
{%- call clipboard_button(invite.landing_page, class="primary") -%}
|
||||
{% trans %}Copy invite link to clipboard{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call share_button("Invitation to Snikket", invite.landing_page, class="primary") -%}
|
||||
{% trans %}Share invitation link{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call custom_form_button("remove_link", form.action_revoke.name, invite.id_, class="secondary danger", slim=True) -%}
|
||||
{% trans %}Delete invitation{% endtrans %}
|
||||
{%- endcall -%}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<dt>{% trans %}Valid until{% endtrans %}</dt>
|
||||
<dd>{{ reset_link.expires | format_date }}</dd>
|
||||
<dt><label for="link-field">{% trans %}Link{% endtrans %}</label></dt>
|
||||
<dd>{% call showuri(reset_link.landing_page, id_="link-field") %}Reset your Snikket password{% endcall %}</dd>
|
||||
<dd>{% call showuri(reset_link.landing_page, id_="link-field") %}{% endcall %}</dd>
|
||||
</dd>
|
||||
<div class="f-bbox">
|
||||
{%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%}
|
||||
|
||||
@@ -1,112 +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 %}Active users{% endtrans %}</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{%- if metrics.prosody_devices | default(None) is not none -%}
|
||||
<li>{% trans %}Connected now:{% endtrans %} {{ metrics.prosody_devices }}</li>
|
||||
{%- else -%}
|
||||
<li><em>{% trans %}unknown{% endtrans %}</em></li>
|
||||
{%- endif -%}
|
||||
{%- if metrics.users | default(None) is not none -%}
|
||||
<li>{% trans %}Past 24 hours:{% endtrans %} {{ metrics.users.active_1d }}</li>
|
||||
<li>{% trans %}Past 7 days:{% endtrans %} {{ metrics.users.active_7d }}</li>
|
||||
<li>{% trans %}Past 30 days:{% endtrans %} {{ metrics.users.active_30d }}</li>
|
||||
{%- endif -%}
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h2>{% trans %}Broadcast message{% endtrans %}</h2>
|
||||
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
|
||||
<p class="form-desc">{% trans %}This form allows you to send a message to all users currently online on your Snikket server. Use it wisely.{% endtrans %}</p>
|
||||
<div class="f-ebox">
|
||||
{{ form.text.label }}
|
||||
{{ form.text }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.online_only }}{{ form.online_only.label }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("send", form.action_send_preview, class="primary") -%}{%- endcall -%}
|
||||
{%- call form_button("broadcast", form.action_post_all, class="secondary accent") -%}{%- endcall -%}
|
||||
</div>
|
||||
</div></form>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,12 @@
|
||||
{% 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 %}
|
||||
<h1>{% trans %}Manage users{% endtrans %}</h1>
|
||||
<div class="elevated el-2"><table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}User{% endtrans %}</th>
|
||||
<th>{% trans %}Last active{% endtrans %}</th>
|
||||
<th>{% trans %}Login name{% endtrans %}</th>
|
||||
<th>{% trans %}Display name{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -14,19 +14,20 @@
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<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>
|
||||
{% if user.enabled %}
|
||||
<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>{% call value_or_hint(user.display_name) %}{% endcall %}</td>
|
||||
<td class="nowrap">
|
||||
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%}
|
||||
{% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %} class="{% if is_in_debug_mode %}debug{% endif %}{% if body_class | default(False) %} {{ body_class }}{% endif %} no-copy no-share"{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
|
||||
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %} class="{% if is_in_debug_mode %}debug{% endif %}{% if body_class | default(False) %} {{ body_class }}{% endif %}"{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
|
||||
</html>
|
||||
|
||||
@@ -115,63 +115,8 @@ var copy_to_clipboard_btn = function(el) {
|
||||
});
|
||||
};
|
||||
|
||||
var copy_to_clipboard_btn = function(el) {
|
||||
var text = el.dataset.cliptext;
|
||||
if (!text) {
|
||||
console.error('copy_to_clipboard used on element without text to copy');
|
||||
}
|
||||
copyTextToClipboard(text, el, function(success) {
|
||||
var existing_result_el = document.getElementById("clipboard-result");
|
||||
if (existing_result_el !== null) {
|
||||
existing_result_el.parentNode.removeChild(existing_result_el);
|
||||
}
|
||||
|
||||
var icon = "done";
|
||||
if (!success) {
|
||||
icon = "cancel";
|
||||
}
|
||||
var icon_bak = get_current_icon(el.firstChild);
|
||||
change_icon(el.firstChild, icon);
|
||||
setTimeout(function() {
|
||||
change_icon(el.firstChild, icon_bak);
|
||||
el.blur();
|
||||
}, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
var share_url_btn = function(el) {
|
||||
let data = {
|
||||
"title": el.dataset.shareTitle,
|
||||
"url": el.dataset.shareUrl,
|
||||
}
|
||||
|
||||
let icon_bak = get_current_icon(el.firstChild);
|
||||
|
||||
new Promise(function (resolve, reject) {
|
||||
if(!navigator.canShare || !navigator.canShare(data)) {
|
||||
return reject();
|
||||
}
|
||||
return resolve(navigator.share(data));
|
||||
}).then(function () {
|
||||
// Success
|
||||
change_icon(el.firstChild, "done");
|
||||
}, function () {
|
||||
// Failure
|
||||
change_icon(el.firstChild, "cancel");
|
||||
}).finally(function () {
|
||||
// Either way, clear status icon after 1.5s
|
||||
setTimeout(function() {
|
||||
change_icon(el.firstChild, icon_bak);
|
||||
el.blur();
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
document.body.classList.remove("no-copy");
|
||||
if(navigator.share) {
|
||||
document.body.classList.remove("no-share");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ 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>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.password_confirm.label }}
|
||||
{{ form.password_confirm(autocomplete="new-password") }}
|
||||
{{ form.password_confirm }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("done", form.action_register, class="primary") -%}{%- endcall -%}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
<title>{% trans %}Reset your password | Snikket{% endtrans %}</title>
|
||||
<script async type="text/javascript" src="{{ url_for("static", filename="js/qrcode.min.js") }}"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<form method="POST"><div class="form layout-expanded">
|
||||
@@ -16,14 +17,19 @@
|
||||
{%- call render_errors(form) %}{% endcall -%}
|
||||
<div class="f-ebox">
|
||||
{{ form.password.label }}
|
||||
{{ form.password(autocomplete="new-password") }}
|
||||
{{ form.password }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.password_confirm.label }}
|
||||
{{ form.password_confirm(autocomplete="new-password") }}
|
||||
{{ form.password_confirm }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("passwd", form.action_reset, class="primary") -%}{%- endcall -%}
|
||||
</div>
|
||||
</div></form>
|
||||
<script type="text/javascript">
|
||||
var onload = function() {
|
||||
apply_qr_code(document.getElementById("qr-uri"));
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "invite.html" %}
|
||||
{% 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 %}
|
||||
<title>{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %}</title>
|
||||
{%- include "copy-snippet.html" -%}
|
||||
@@ -15,47 +15,6 @@
|
||||
{% trans %}Copy address{% endtrans %}
|
||||
{%- endcall -%}
|
||||
<p>{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p>
|
||||
<p>{% trans login_url=url_for('main.login') %}You can now safely close this page, or log in to the web portal to <a href="{{ login_url }}">manage your account</a>.{% endtrans %}</p>
|
||||
|
||||
{% if migration_success %}
|
||||
<h2>{% trans %}Import successful{% endtrans %}</h2>
|
||||
<p>{% trans %}Congratulations! Your account data has been successfully imported.{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if form %}
|
||||
<h2>{% trans %}Moving to Snikket?{% endtrans %}</h2>
|
||||
<p>{% trans %}If you are moving from a different Snikket instance or another XMPP-compatible service, you may optionally import the data (contacts, profile information, etc.) from your previous account. When you have exported the data from your previous account, upload it using the form below.{% endtrans %}</p>
|
||||
|
||||
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
|
||||
<h3 class="form-title">{% trans %}Upload account data{% endtrans %}</h3>
|
||||
{{ form.csrf_token }}
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
<div class="f-ebox">
|
||||
{{ form.account_data_file.label }}
|
||||
{{ form.account_data_file(accept="application/xml",
|
||||
data_maxsize=max_import_size,
|
||||
data_warning_header=import_too_big_warning_header,
|
||||
data_maxsize_warning=import_too_big_warning) }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("upload", form.action_import, class="secondary") %}{% endcall -%}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.getElementById("{{ form.account_data_file.id }}").onchange = function() {
|
||||
var maxsize_s = this.dataset.maxsize;
|
||||
var maxsize = parseInt(maxsize_s);
|
||||
if (this.files[0].size > maxsize) {
|
||||
var warning_header = this.dataset.warningHeader;
|
||||
var warning_text = this.dataset.maxsizeWarning;
|
||||
this.setCustomValidity(warning_text);
|
||||
this.reportValidity();
|
||||
this.value = null;
|
||||
} else {
|
||||
this.setCustomValidity("");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</form></div>
|
||||
{% endif %}
|
||||
<p>{% trans %}You can now safely close this page.{% endtrans %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,13 +17,6 @@
|
||||
{%- 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>
|
||||
{%- 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>
|
||||
{%- if apple_store_url -%}
|
||||
<p>{% trans %}Install the Snikket App on your Android or iOS device.{% endtrans %}</p>
|
||||
@@ -32,7 +25,7 @@
|
||||
{%- endif -%}
|
||||
<div class="install-buttons">
|
||||
<ul>
|
||||
<li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='{{ play_store_badge() }}' class="play"/></a></li>
|
||||
<li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' class="play"/></a></li>
|
||||
{%- if apple_store_url -%}
|
||||
<li><a href="{{ apple_store_url }}" class="popover" data-popover-id="apple-popover"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></li>
|
||||
{%- endif -%}
|
||||
@@ -63,7 +56,29 @@
|
||||
{%- endcall -%}
|
||||
</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>
|
||||
<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") -%}
|
||||
{% trans %}Close{% endtrans %}
|
||||
@@ -134,6 +149,7 @@
|
||||
|
||||
var onload = function() {
|
||||
apply_qr_code(document.getElementById("qr-invite-page"));
|
||||
apply_qr_code(document.getElementById("qr-uri"));
|
||||
var popover_as = document.getElementsByClassName("popover");
|
||||
for (var i = 0; i < popover_as.length; ++i) {
|
||||
var a = popover_as[i];
|
||||
|
||||
@@ -10,38 +10,12 @@
|
||||
{%- endif -%}
|
||||
{%- 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">
|
||||
{%- if user.display_name %}
|
||||
<div class="user-display-name">{{- user.display_name -}}</div>
|
||||
{%- endif %}
|
||||
<div class="user-jid"><span class="user-jid-localpart">{{- user.localpart -}}</span><span class="user-jid-at">@</span><span class="user-jid-domain">{{- config["SNIKKET_DOMAIN"] -}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro -%}
|
||||
|
||||
{% macro showuri(uri, caller=None, id_=None) %}
|
||||
{%- if uri is none -%}
|
||||
<em>—</em>
|
||||
{%- else -%}
|
||||
<div><input type="text" {% if id_ %}id="{{ id_ }}" {% endif %}readonly="readonly" value="{{ uri }}"></div>
|
||||
<div>
|
||||
{% call clipboard_button(uri, show_label=True) %}{% trans %}Copy link{% endtrans %}{% endcall %}
|
||||
{% call share_button(caller() if caller is not none else None, uri, show_label=True) %}{% trans %}Share{% endtrans %}{% endcall %}
|
||||
</div>
|
||||
<div>{% call clipboard_button(uri, show_label=True) %}{% trans %}Copy link{% endtrans %}{% endcall %}</div>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -85,7 +59,7 @@
|
||||
|
||||
{% macro clipboard_button(data, show_label=False, caller=None, class=None) -%}
|
||||
{%- set label = caller() -%}
|
||||
<a class="button copy-to-clipboard{% if class %} {{ class }}{% endif %}"
|
||||
<a class="button{% if class %} {{ class }}{% endif %}"
|
||||
href="#"
|
||||
{% if not show_label %}
|
||||
aria-label="{{ label }}"
|
||||
@@ -100,24 +74,6 @@
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro share_button(title, url, show_label=False, caller=None, class=None) -%}
|
||||
{%- set label = caller() -%}
|
||||
<a class="button share-button{% if class %} {{ class }}{% endif %}"
|
||||
href="#"
|
||||
{% if not show_label %}
|
||||
aria-label="{{ label }}"
|
||||
title="{{ label }}"
|
||||
{% endif %}
|
||||
data-share-title="{{ title }}"
|
||||
data-share-url="{{ url }}"
|
||||
onclick="share_url_btn(this); return false;">
|
||||
{%- call icon("share") %}{% endcall -%}
|
||||
{%- if show_label %}
|
||||
<span>{{ label }}</span>
|
||||
{% endif -%}
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro render_errors(field, caller=None) -%}
|
||||
{%- set error_list = field.errors if field.errors is not mapping else (field.errors.values() | flatten | list) -%}
|
||||
{%- if error_list -%}
|
||||
@@ -153,44 +109,18 @@
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{%- macro invite_type_name(invite_type, caller=None) -%}
|
||||
{%- if invite_type == "account" -%}
|
||||
{% trans %}Individual{% endtrans %}
|
||||
{%- else -%}
|
||||
{%- macro invite_type_name(invite_info, caller=None) -%}
|
||||
{%- if invite_info.reusable -%}
|
||||
{% trans %}Group{% endtrans %}
|
||||
{%- else -%}
|
||||
{% trans %}Individual{% endtrans %}
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{% macro access_level_description(role, caller=None) %}
|
||||
{%- if role == "prosody:restricted" -%}
|
||||
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
|
||||
{%- elif role == "prosody:registered" -%}
|
||||
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
|
||||
{%- elif role == "prosody:admin" -%}
|
||||
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}
|
||||
{%- macro invite_type_description(invite_info, caller=None) -%}
|
||||
{%- if invite_info.reusable -%}
|
||||
{% trans %}Can be used multiple times to create accounts on this Snikket service.{% endtrans %}
|
||||
{%- else -%}
|
||||
{% trans %}Can be used once to create an account on this Snikket service.{% endtrans %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro access_level_icon(role, caller=None) %}
|
||||
{%- if role == "prosody:restricted" -%}
|
||||
{% call icon("lock") %}{% endcall %}
|
||||
{%- elif role == "prosody:admin" -%}
|
||||
{% call icon("admin") %}{% endcall %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invite_type_description(invite_type, caller=None) %}
|
||||
{%- if invite_type == "account" -%}
|
||||
{% trans %}Invite a single person (invitation link can only be used once).{% endtrans %}
|
||||
{%- elif invite_type == "group" -%}
|
||||
{% trans %}Invite a group of people (invitation link can be used multiple times).{% endtrans %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invite_type_icon(invite_type, caller=None) %}
|
||||
{%- if invite_type == "account" -%}
|
||||
{% call icon("person") %}{% endcall %}
|
||||
{%- elif invite_type == "group" -%}
|
||||
{% call icon("people") %}{% endcall %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{%- endmacro -%}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
|
||||
</div>
|
||||
</form>
|
||||
</from>
|
||||
<script type="text/javascript">
|
||||
var domainCheck = function() {
|
||||
var form = document.getElementById("login-form");
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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/
|
||||
@@ -6,13 +6,8 @@
|
||||
{% include "copy-snippet.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if user_info.is_admin and metrics.users and metrics.users.active_1d <= 1 %}
|
||||
<aside class="box hint">
|
||||
<header>{% trans %}Welcome to Snikket!{% endtrans %}</header>
|
||||
<p>{% trans %}Now your Snikket instance is up and running, the next step is to invite people to join it. Family, friends, colleagues... you choose!{% endtrans %}</p>
|
||||
<a href="/admin/invitations">{% trans %}Create new invitation{% endtrans %}</a>
|
||||
</aside>
|
||||
{% endif %}
|
||||
<h1>{% trans %}Welcome!{% endtrans %}</h1>
|
||||
<p>{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}</p>
|
||||
<nav class="welcome">
|
||||
<ul>
|
||||
<li class="wide">
|
||||
@@ -35,7 +30,6 @@
|
||||
<div>
|
||||
<div>{% call standard_button("edit", url_for(".profile"), class="primary") %}{% trans %}Edit profile{% endtrans %}{% endcall %}</div>
|
||||
<div>{% call standard_button("passwd", url_for(".change_pw"), class="secondary") %}{% trans %}Change password{% endtrans %}{% endcall %}</div>
|
||||
<div>{% call standard_button("folder", url_for(".manage_data"), class="secondary") %}{% trans %}Manage your data{% endtrans %}{% endcall %}</div>
|
||||
</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
|
||||
@@ -1,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 %}
|
||||
@@ -9,15 +9,15 @@
|
||||
{%- endcall -%}
|
||||
<div class="f-ebox">
|
||||
{{ form.current_password.label(class="required") }}
|
||||
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else ""), autocomplete="current-password") }}
|
||||
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else "")) }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.new_password.label(class="required") }}
|
||||
{{ form.new_password(autocomplete="new-password") }}
|
||||
{{ form.new_password }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.new_password_confirm.label(class="required") }}
|
||||
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else ""), autocomplete="new-password") }}
|
||||
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else "")) }}
|
||||
</div>
|
||||
<div class="box warning">
|
||||
<header>{% trans %}Warning{% endtrans %}</header>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import asyncio
|
||||
import typing
|
||||
import urllib
|
||||
|
||||
import quart.flask_patch
|
||||
from quart import (
|
||||
Blueprint,
|
||||
Response,
|
||||
render_template,
|
||||
request,
|
||||
redirect,
|
||||
@@ -13,7 +11,7 @@ from quart import (
|
||||
flash,
|
||||
current_app,
|
||||
)
|
||||
import werkzeug.exceptions
|
||||
import quart.exceptions
|
||||
|
||||
import wtforms
|
||||
|
||||
@@ -32,22 +30,16 @@ class ChangePasswordForm(BaseForm):
|
||||
|
||||
new_password = wtforms.PasswordField(
|
||||
_l("New password"),
|
||||
validators=[
|
||||
wtforms.validators.InputRequired(),
|
||||
wtforms.validators.Length(min=10),
|
||||
]
|
||||
validators=[wtforms.validators.InputRequired()]
|
||||
)
|
||||
|
||||
new_password_confirm = wtforms.PasswordField(
|
||||
_l("Confirm new password"),
|
||||
validators=[
|
||||
wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"new_password",
|
||||
_l("The new passwords must match.")
|
||||
),
|
||||
wtforms.validators.Length(min=10),
|
||||
]
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"new_password",
|
||||
_l("The new passwords must match.")
|
||||
)]
|
||||
)
|
||||
|
||||
|
||||
@@ -65,7 +57,7 @@ _ACCESS_MODEL_CHOICES = [
|
||||
|
||||
|
||||
class ProfileForm(BaseForm):
|
||||
nickname = wtforms.StringField(
|
||||
nickname = wtforms.TextField(
|
||||
_l("Display name"),
|
||||
)
|
||||
|
||||
@@ -83,34 +75,16 @@ class ProfileForm(BaseForm):
|
||||
)
|
||||
|
||||
|
||||
class ImportAccountDataForm(BaseForm):
|
||||
account_data_file = wtforms.FileField(
|
||||
_l("Account data")
|
||||
)
|
||||
|
||||
action_upload = wtforms.SubmitField(
|
||||
_l("Upload"),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@client.require_session()
|
||||
async def index() -> str:
|
||||
user_info = await client.get_user_info()
|
||||
try:
|
||||
metrics = await client.get_system_metrics()
|
||||
except (werkzeug.exceptions.Unauthorized, werkzeug.exceptions.Forbidden):
|
||||
metrics = {}
|
||||
return await render_template(
|
||||
"user_home.html",
|
||||
user_info=user_info,
|
||||
metrics=metrics,
|
||||
)
|
||||
return await render_template("user_home.html", user_info=user_info)
|
||||
|
||||
|
||||
@bp.route('/passwd', methods=["GET", "POST"])
|
||||
@client.require_session()
|
||||
async def change_pw() -> typing.Union[str, werkzeug.Response]:
|
||||
async def change_pw() -> typing.Union[str, quart.Response]:
|
||||
form = ChangePasswordForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
@@ -118,8 +92,8 @@ async def change_pw() -> typing.Union[str, werkzeug.Response]:
|
||||
form.current_password.data,
|
||||
form.new_password.data,
|
||||
)
|
||||
except (werkzeug.exceptions.Unauthorized,
|
||||
werkzeug.exceptions.Forbidden):
|
||||
except (quart.exceptions.Unauthorized,
|
||||
quart.exceptions.Forbidden):
|
||||
# server refused current password, set an appropriate error
|
||||
form.current_password.errors.append(
|
||||
_("Incorrect password."),
|
||||
@@ -142,7 +116,7 @@ EAVATARTOOBIG = _l(
|
||||
|
||||
@bp.route("/profile", methods=["GET", "POST"])
|
||||
@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"]
|
||||
|
||||
form = ProfileForm()
|
||||
@@ -164,6 +138,7 @@ async def profile() -> typing.Union[str, werkzeug.Response]:
|
||||
mimetype = file_info.mimetype
|
||||
data = file_info.stream.read()
|
||||
if len(data) > max_avatar_size:
|
||||
print(len(data), max_avatar_size)
|
||||
form.avatar.errors.append(EAVATARTOOBIG)
|
||||
ok = False
|
||||
elif len(data) > 0:
|
||||
@@ -193,49 +168,9 @@ async def profile() -> typing.Union[str, werkzeug.Response]:
|
||||
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"])
|
||||
@client.require_session()
|
||||
async def logout() -> typing.Union[werkzeug.Response, str]:
|
||||
async def logout() -> typing.Union[quart.Response, str]:
|
||||
form = LogoutForm()
|
||||
if form.validate_on_submit():
|
||||
await client.logout()
|
||||
|
||||
@@ -4,7 +4,7 @@ import typing
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from quart import abort
|
||||
import werkzeug.exceptions
|
||||
import quart.exceptions
|
||||
|
||||
|
||||
TAG_XMPP_ERROR = "error"
|
||||
@@ -207,7 +207,7 @@ def make_avatar_metadata_set_request(
|
||||
item,
|
||||
"metadata", xmlns=NS_USER_AVATAR_METADATA)
|
||||
|
||||
attr: typing.Dict[str, str] = {
|
||||
attr: typing.MutableMapping[str, str] = {
|
||||
"id": id_,
|
||||
"bytes": str(size),
|
||||
"type": mimetype,
|
||||
@@ -217,12 +217,7 @@ def make_avatar_metadata_set_request(
|
||||
if height is not None:
|
||||
attr["height"] = str(height)
|
||||
|
||||
ET.SubElement(
|
||||
metadata_wrap,
|
||||
"info",
|
||||
xmlns=NS_USER_AVATAR_METADATA,
|
||||
**attr, # type: ignore
|
||||
)
|
||||
ET.SubElement(metadata_wrap, "info", xmlns=NS_USER_AVATAR_METADATA, **attr)
|
||||
return req
|
||||
|
||||
|
||||
@@ -239,7 +234,7 @@ def extract_pubsub_item_get_reply(
|
||||
) -> typing.Optional[ET.Element]:
|
||||
try:
|
||||
pubsub = extract_iq_reply(iq_tree, TAG_PUBSUB)
|
||||
except werkzeug.exceptions.NotFound:
|
||||
except quart.exceptions.NotFound:
|
||||
return None
|
||||
|
||||
if pubsub is None:
|
||||
|
||||
@@ -6,28 +6,19 @@ action/logout:logout
|
||||
action/login:login
|
||||
action/exit_to_app:exit_to_app
|
||||
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/vpn_key:passwd
|
||||
communication/rss_feed:broadcast
|
||||
content/add_circle_outline:add
|
||||
content/add_link:create_link
|
||||
content/remove_circle_outline:remove
|
||||
content/content_copy:copy
|
||||
content/link_off:remove_link
|
||||
content/send:send
|
||||
file/file_download:download
|
||||
file/file_upload:upload
|
||||
file/folder:folder
|
||||
navigation/arrow_back:back
|
||||
navigation/arrow_forward:forward
|
||||
navigation/cancel:cancel
|
||||
navigation/more_vert:more
|
||||
social/groups:groups
|
||||
social/people:people
|
||||
social/person:person
|
||||
social/group_add:create_group
|
||||
social/person_add:add_user
|
||||
social/person_remove:remove_user
|
||||
@@ -35,5 +26,3 @@ navigation/close:close
|
||||
image/edit:edit
|
||||
action/admin_panel_settings:admin
|
||||
content/link:link
|
||||
content/insights:insights
|
||||
social/share:share
|
||||
|
||||
6
tools/import-icons.sh
Executable file → Normal file
@@ -9,9 +9,9 @@ set -euo pipefail
|
||||
# FLAVOR one of '', 'round', 'sharp', 'outlined', 'twoshade'
|
||||
# SVGOUT path to the newly created SVG file
|
||||
root="$1/src"
|
||||
iconlist_file="${2-tools/icons.list}"
|
||||
flavor="${3-round}"
|
||||
output_file="${4-snikket_web/static/img/icons.svg}"
|
||||
iconlist_file="$2"
|
||||
flavor="$3"
|
||||
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 '<!-- These icons are sourced from Google’s Material Icons set,\nlicensed under the terms of the Apache 2.0 License -->\n' >> "$output_file"
|
||||
|
||||