Compare commits

..

1 Commits

Author SHA1 Message Date
Jonas Schäfer
788ca73d86 WIP: implement setting circle avatar
missing: actually doing the thing, we get forbidden back because the
role doesn't propagate to groups.$SNIKKET_DOMAIN

Fixes #49.
2023-03-28 21:47:24 +02:00
60 changed files with 3766 additions and 9521 deletions

View File

@@ -66,10 +66,10 @@ jobs:
pip install flask-babel
- name: Linting
run: |
sed -ri '/^"POT-Creation-Date: /d;/^"Generated-By: /d' snikket_web/translations/messages.pot
sed -ri '/^"POT-Creation-Date: /d' snikket_web/translations/messages.pot
git add snikket_web/translations/messages.pot
make extract_translations
sed -ri '/^"POT-Creation-Date: /d;/^"Generated-By: /d' snikket_web/translations/messages.pot
sed -ri '/^"POT-Creation-Date: /d' snikket_web/translations/messages.pot
git diff --exit-code --color -- snikket_web/translations/messages.pot

View File

@@ -1,22 +1,28 @@
FROM debian:bookworm-slim AS build
FROM debian:bullseye-slim AS build
RUN set -eu; \
export DEBIAN_FRONTEND=noninteractive ; \
apt-get update ; \
apt-get install -y --no-install-recommends \
python3 python3-mypy python3-dotenv python3-toml python3-babel python3-distutils \
sassc make;
python3 python3-pip python3-setuptools python3-wheel \
libpython3-dev \
make build-essential;
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt
COPY Makefile /opt/snikket-web-portal/Makefile
COPY snikket_web/ /opt/snikket-web-portal/snikket_web
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
WORKDIR /opt/snikket-web-portal
RUN make
RUN set -eu; \
pip3 install -r requirements.txt; \
pip3 install -r build-requirements.txt; \
make;
FROM debian:bookworm-slim
FROM debian:bullseye-slim
ARG BUILD_SERIES=dev
ARG BUILD_ID=0
@@ -27,19 +33,19 @@ ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
WORKDIR /opt/snikket-web-portal
RUN set -eu; \
export DEBIAN_FRONTEND=noninteractive ; \
apt-get update ; \
apt-get install -y --no-install-recommends \
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; \
python3 python3-pip python3-setuptools python3-wheel build-essential libpython3-dev netcat; \
pip3 install -r requirements.txt; \
apt-get remove -y --autoremove build-essential libpython3-dev; \
apt-get clean ; rm -rf /var/lib/apt/lists; \
pip3 install hypercorn; \
rm -rf /root/.cache;
HEALTHCHECK CMD nc -zv ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE:-127.0.0.1} ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT:-5765}

View File

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

View File

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

View File

@@ -5,11 +5,6 @@ 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}"

View File

@@ -1,10 +1,9 @@
aiohttp~=3.8,<3.9
quart~=0.18,<0.19
flask-wtf~=1.1,<1.2
aiohttp~=3.6
quart~=0.17,<0.18
flask-wtf~=1.0
hsluv~=5.0
flask-babel~=2.0,<3
email-validator~=1.3
flask-babel~=1.0
email-validator~=1.1
environ-config~=20.0
wtforms~=3.0,<4
wtforms~=3.0
typing-extensions
werkzeug~=2.2,<3

View File

@@ -158,9 +158,7 @@ class AppConfig:
"id",
"it",
"pl",
"ru",
"sv",
"uk",
"zh_Hans_CN",
], converter=autosplit)
apple_store_url = environ.var(
@@ -172,10 +170,6 @@ class AppConfig:
# 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)))
@@ -208,10 +202,6 @@ def create_app() -> quart.Quart:
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.context_processor(proc)
app.register_error_handler(

View File

@@ -28,6 +28,7 @@ from flask_babel import lazy_gettext as _l, _
from . import prosodyclient, _version
from .infra import client, circle_name, BaseForm
from .user import EAVATARTOOBIG
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -76,8 +77,8 @@ class EditUserForm(BaseForm):
role = wtforms.RadioField(
_l("Access Level"),
choices=[
("prosody:restricted", _l("Limited")),
("prosody:registered", _l("Normal user")),
("prosody:restricted", _("Limited")),
("prosody:normal", _l("Normal user")),
("prosody:admin", _l("Administrator")),
],
)
@@ -86,14 +87,6 @@ class EditUserForm(BaseForm):
_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"),
)
@@ -120,44 +113,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
@@ -165,7 +132,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",
@@ -477,6 +444,10 @@ class EditCircleForm(BaseForm):
validators=[wtforms.validators.InputRequired()],
)
avatar = wtforms.FileField(
_l("Avatar")
)
user_to_add = wtforms.SelectField(
_l("Select user"),
validate_choice=False,
@@ -486,18 +457,22 @@ 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]:
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
@@ -543,10 +518,31 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
id_,
new_name=form.name.data,
)
file_info = (await request.files).get(form.avatar.name)
if file_info is not None:
mimetype = file_info.mimetype
data = file_info.stream.read()
if len(data) > max_avatar_size:
form.avatar.errors.append(EAVATARTOOBIG)
ok = False
elif len(data) > 0:
print("setting muc avatar")
await client.set_muc_avatar(
circle.muc_jid,
data,
mimetype,
)
await flash(
_("Circle data updated"),
"success",
)
elif form.action_delete.data:
await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles"))
elif form.action_add_user.data:
if form.user_to_add.data in valid_users:
await client.add_group_member(
@@ -566,15 +562,6 @@ 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_))
@@ -582,100 +569,11 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
"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,
max_avatar_size=max_avatar_size,
avatar_too_big_warning_header=_l("Error"),
avatar_too_big_warning=EAVATARTOOBIG,
)
@@ -733,21 +631,21 @@ def get_system_stats() -> typing.MutableMapping[
class AnnouncementForm(BaseForm):
text = wtforms.StringField(
_l("Message contents"),
_("Message contents"),
widget=wtforms.widgets.TextArea(),
validators=[wtforms.validators.DataRequired()],
)
online_only = wtforms.BooleanField(
_l("Only send to online users"),
_("Only send to online users"),
)
action_post_all = wtforms.SubmitField(
_l("Post to all users"),
_("Post to all users"),
)
action_send_preview = wtforms.SubmitField(
_l("Send preview to yourself"),
_("Send preview to yourself"),
)

View File

@@ -4,8 +4,6 @@ import math
import secrets
import typing
from datetime import datetime, timedelta, timezone
import quart.flask_patch # noqa:F401
from quart import (
current_app,
@@ -15,8 +13,7 @@ from quart import (
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
@@ -53,7 +50,7 @@ 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
@@ -73,43 +70,6 @@ def format_bytes(n: float) -> str:
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")
@@ -126,8 +86,6 @@ def init_templating(app: quart.Quart) -> None:
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)

View File

@@ -116,10 +116,6 @@ class RegisterForm(BaseForm):
password = wtforms.PasswordField(
_l("Password"),
validators=[
wtforms.validators.InputRequired(),
wtforms.validators.Length(min=10),
],
)
password_confirm = wtforms.PasswordField(
@@ -188,10 +184,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(

View File

@@ -173,42 +173,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")

View File

@@ -9,28 +9,25 @@ import types
import typing
import typing_extensions
from datetime import datetime, timezone
from datetime import datetime
import aiohttp
import xml.etree.ElementTree as ET
from quart import (
current_app, session as http_session, abort, redirect,
current_app, _app_ctx_stack, session as http_session, abort, redirect,
url_for,
)
import quart
from flask import g as _app_ctx_stack
import werkzeug.exceptions
from . import xmpputil
from .xmpputil import split_jid
SCOPE_RESTRICTED = "prosody:restricted"
SCOPE_DEFAULT = "prosody:registered"
SCOPE_DEFAULT = "prosody:user"
SCOPE_ADMIN = "prosody:admin"
@@ -43,52 +40,6 @@ class TokenInfo:
scopes: typing.Collection[str]
@dataclasses.dataclass(frozen=True)
class UserDeletionRequestInfo:
deleted_at: datetime
pending_until: datetime
@classmethod
def from_api_response(
cls,
data: typing.Optional[typing.Mapping[str, typing.Any]],
) -> typing.Optional["UserDeletionRequestInfo"]:
if data is None:
return None
return cls(
deleted_at=datetime.fromtimestamp(
data["deleted_at"],
tz=timezone.utc
),
pending_until=datetime.fromtimestamp(
data["pending_until"],
tz=timezone.utc
)
)
@dataclasses.dataclass(frozen=True)
class AvatarMetadata:
bytes: int
hash: str
type: str
width: typing.Optional[int]
height: typing.Optional[int]
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AvatarMetadata":
return cls(
hash=data["hash"],
bytes=data["bytes"],
type=data["type"],
width=data.get("width") or None,
height=data.get("height") or None,
)
@dataclasses.dataclass(frozen=True)
class AdminUserInfo:
localpart: str
@@ -96,10 +47,6 @@ class AdminUserInfo:
email: typing.Optional[str]
phone: typing.Optional[str]
roles: typing.Optional[typing.List[str]]
enabled: bool
last_active: typing.Optional[int]
deletion_request: typing.Optional[UserDeletionRequestInfo]
avatar_info: typing.List[AvatarMetadata]
@property
def has_admin_role(self) -> bool:
@@ -114,27 +61,12 @@ class AdminUserInfo:
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminUserInfo":
try:
roles: typing.Optional[typing.List[str]] = [data["role"]]
assert roles is not None # make mypy happy
roles.extend(data.get("secondary_roles", []))
except KeyError:
roles = data.get("roles")
return cls(
localpart=data["username"],
display_name=data.get("display_name") or None,
email=data.get("email") or None,
phone=data.get("phone") or None,
roles=roles,
enabled=data.get("enabled", True),
last_active=data.get("last_active") or None,
deletion_request=UserDeletionRequestInfo.from_api_response(
data.get("deletion_request")
),
avatar_info=[
AvatarMetadata.from_api_response(avatar_info)
for avatar_info in data.get("avatar_info", [])
],
roles=data.get("roles"),
)
@@ -177,30 +109,12 @@ class AdminInviteInfo:
)
@dataclasses.dataclass(frozen=True)
class AdminGroupChatInfo:
id_: str
jid: str
name: str
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminGroupChatInfo":
return cls(
id_=data["id"],
jid=data["jid"],
name=data.get("name", ""),
)
@dataclasses.dataclass(frozen=True)
class AdminGroupInfo:
id_: str
name: str
muc_jid: typing.Optional[str]
members: typing.Collection[str]
chats: typing.Collection[AdminGroupChatInfo]
@classmethod
def from_api_response(
@@ -210,11 +124,8 @@ class AdminGroupInfo:
return cls(
id_=data["id"],
name=data["name"],
muc_jid=data.get("muc_jid") or None,
members=data.get("members", []),
chats=[
AdminGroupChatInfo.from_api_response(x)
for x in data.get("chats", [])
]
)
@@ -249,7 +160,7 @@ class HTTPSessionManager:
})
async def teardown(self, exc: typing.Optional[BaseException]) -> None:
app_ctx = _app_ctx_stack
app_ctx = _app_ctx_stack.top
try:
session = getattr(app_ctx, self._app_context_attribute)
except AttributeError:
@@ -266,7 +177,7 @@ class HTTPSessionManager:
await session.__aexit__(exc_type, exc, traceback)
async def __aenter__(self) -> aiohttp.ClientSession:
app_ctx = _app_ctx_stack
app_ctx = _app_ctx_stack.top
try:
return getattr(app_ctx, self._app_context_attribute)
except AttributeError:
@@ -400,7 +311,7 @@ class ProsodyClient:
request.add_field("password", password)
request.add_field(
"scope",
" ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN])
" ".join([SCOPE_DEFAULT, SCOPE_ADMIN])
)
self.logger.debug("sending OAuth2 request (payload omitted)")
@@ -909,7 +820,7 @@ class ProsodyClient:
self.session_address,
current_password,
)
password_changed = await self._xml_iq_call(
await self._xml_iq_call(
session,
xmpputil.make_password_change_request(
self.session_address,
@@ -920,7 +831,7 @@ class ProsodyClient:
},
sensitive=True,
)
xmpputil.extract_iq_reply(password_changed)
# TODO: error handling
# TODO: obtain a new token using the new password to allow the
# server to expire/revoke all tokens on password change.
self._store_token_in_session(token_info)
@@ -968,7 +879,7 @@ class ProsodyClient:
localpart: str,
*,
display_name: typing.Optional[str],
role: typing.Optional[str],
roles: typing.Optional[typing.Collection[str]],
session: aiohttp.ClientSession,
) -> None:
payload: typing.Dict[str, typing.Any] = {
@@ -976,8 +887,8 @@ class ProsodyClient:
}
if display_name is not None:
payload["display_name"] = display_name
if role is not None:
payload["role"] = role
if roles is not None:
payload["roles"] = list(roles)
async with session.put(
self._admin_v1_endpoint("/users/{}".format(localpart)),
@@ -985,36 +896,6 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def enable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": True,
},
) as resp:
self._raise_error_from_response(resp)
@autosession
async def disable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": False,
},
) as resp:
self._raise_error_from_response(resp)
@autosession
async def get_user_debug_info(
self,
@@ -1143,7 +1024,7 @@ class ProsodyClient:
self,
name: str,
*,
create_muc: bool = False,
create_muc: bool = True,
session: aiohttp.ClientSession,
) -> AdminGroupInfo:
payload = {
@@ -1218,27 +1099,6 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def add_group_chat(
self,
id_: str,
name: str,
*,
session: aiohttp.ClientSession,
) -> None:
payload: typing.Dict[str, typing.Any] = {
"name": name,
}
async with session.post(
self._admin_v1_endpoint(
"/groups/{}/chats".format(id_)
),
json=payload,
) as resp:
self._raise_error_from_response(resp)
@autosession
async def remove_group_member(
self,
@@ -1254,21 +1114,6 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def remove_group_chat(
self,
group_id: str,
chat_id: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint(
"/groups/{}/chats/{}".format(group_id, chat_id)
),
) as resp:
self._raise_error_from_response(resp)
@autosession
async def delete_group(
self,
@@ -1309,6 +1154,7 @@ class ProsodyClient:
self._raise_error_from_response(resp)
return True
@autosession
async def revoke_token(
self,
*,
@@ -1322,8 +1168,7 @@ class ProsodyClient:
async def logout(self) -> None:
try:
async with self._plain_session as session:
await self.revoke_token(session=session)
await self.revoke_token()
except aiohttp.ClientError:
self.logger.warn("failed to revoke token!",
exc_info=True)
@@ -1401,3 +1246,23 @@ class ProsodyClient:
json=payload) as resp:
self._raise_error_from_response(resp)
resp.raise_for_status()
@autosession
async def set_muc_avatar(
self,
muc_jid: str,
data: bytes,
mimetype: str,
*,
session: aiohttp.ClientSession,
):
xmpputil.extract_iq_reply(
await self._xml_iq_call(
session,
xmpputil.make_muc_avatar_set_request(
muc_jid,
data,
mimetype,
),
)
)

View File

@@ -275,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;
}
}
@@ -646,6 +646,69 @@ input[type="submit"], button, .button {
/* button, .button {
margin: 0 $w-s2;
}
button.lv-primary, .button.lv-primary {
background-color: $gray-500;
color: $gray-900;
border-radius: $w-s4;
border: $w-s4 solid $gray-400;
@each $type, $values in $colours {
&.c-#{$type} {
border-color: nth($values, 4);
background-color: nth($values, 5);
color: nth($values, 9);
}
&.c-#{$type}:hover {
background-color: nth($values, 4);
}
}
}
button.lv-secondary, .button.lv-secondary {
background-color: $gray-700;
color: $gray-100;
border-radius: $w-s4;
@each $type, $values in $colours {
&.c-#{$type} {
background-color: nth($values, 7);
color: nth($values, 1);
}
}
}
button.lv-tertiary, .button.lv-tertiary {
background-color: inherit;
color: $gray-300;
border-radius: $w-s4;
text-decoration: underline;
@each $type, $values in $colours {
&.c-#{$type} {
color: nth($values, 3);
}
}
}
*/
/*
button.lv-secondary.c-#{$type}, .button.lv-secondary.c-#{$type} {
background-color: nth($values, 7);
color: nth($values, 1);
}
button.lv-tertiary.c-#{$type}, .button.lv-tertiary.c-#{$type} {
color: nth($values, 3);
text-decoration: underline;
background-color: transparent;
}
}*/
/* boxes */
.box {
@@ -708,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;
@@ -1057,7 +1121,7 @@ pre.guru-meditation {
}
@each $type in $text-entry-inputs {
input[type=#{$type}] {
input[type=$type] {
background-color: black;
}
@@ -1067,10 +1131,6 @@ pre.guru-meditation {
}
}
label, legend {
color: $gray-800 !important;
}
.box {
background-color: black;
border-color: $gray-800;
@@ -1205,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 */
@@ -1262,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%;
}
}

View File

@@ -42,16 +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" />

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -6,20 +6,16 @@
{% 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 }}">Googles Material Icons</a>, made available by Google under the terms of the <a href="{{ apache20_url }}">Apache 2.0 License</a>.{% endtrans %}</p>
<h3>{% trans %}Trademarks{% endtrans %}</h3>
<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 %}
@@ -31,7 +27,6 @@ Web Portal{% if version %} ({{ version }}){% endif %}
{% 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 %}

View File

@@ -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 -}}

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
{% extends "admin_app.html" %}
{% from "library.j2" import box, form_button, standard_button %}
{% block content %}
<h1>{% trans circle_name=target_circle.name %}Delete circle {{ circle_name }}{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}Delete circle{% endtrans %}</h2>
{{ form.csrf_token }}
<p class="form-descr">{% trans %}Are you sure you want to delete the following circle?{% endtrans %}</p>
<dl>
<dt>{% trans %}Name{% endtrans %}</dt>
<dd>{{ target_circle.name }}</dd>
</dl>
{% call box("alert", _("Danger")) %}
<p>{% trans %}The circle and the corresponding chat will be deleted, permanently and immediately upon pushing the below button. <strong>There is no way back!</strong>{% endtrans %}</p>
{% endcall %}
<div class="f-bbox">
{%- call standard_button("back", url_for(".edit_circle", id_=target_circle.id_), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
</div>
</form></div>
{% endblock %}

View File

@@ -1,18 +1,25 @@
{% 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, icon, avatar with context %}
{% block head_lead %}
{{ super() }}
{% include "copy-snippet.html" %}
{% endblock %}
{% block content %}
<h1>{% trans circle_name=(target_circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %}</h1>
<form method="POST">
<form method="POST" enctype="multipart/form-data">
{{- form.csrf_token -}}
{%- if target_circle.id_ == "default" -%}
<input type="hidden" name="{{ form.name.name }}" value="{{ form.name.data }}">{#- -#}
<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,26 @@
{{ 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-ebox">
{{ form.avatar.label }}
<div class="avatar-wrap">
{{ form.avatar(accept="image/png",
data_maxsize=max_avatar_size,
data_warning_header=avatar_too_big_warning_header,
data_maxsize_warning=avatar_too_big_warning) }}
</div>
</div>
<div class="f-bbox">
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
{% trans %}Return to circle list{% endtrans %}
@@ -30,47 +57,16 @@
<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 class="collapsible">{% trans %}Display name{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</thead>
<tbody>
@@ -78,12 +74,13 @@
<tr>
<td>
{%- if member -%}
{%- call render_user(member) -%}{%- endcall -%}
{{ localpart }}
{%- else -%}
{{ localpart }}
<span class="with-tooltip above" data-tooltip="{% trans %}The user has been deleted from the server.{% endtrans %}"><em> ({% trans %}deleted{% endtrans %})</em></span>
{%- endif -%}
</td>
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
<td class="nowrap">
{%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}
{% trans username=member.localpart %}Remove user {{ username }} from circle{% endtrans %}

View File

@@ -3,7 +3,7 @@
{% 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" -%}
{%- 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 %}
@@ -19,33 +19,12 @@
{% 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 }}
@@ -84,14 +63,14 @@
{% trans %}If the user has lost their password, you can use the button below to create a special link which allows to change the password of the account, once.{% endtrans %}
</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>

View File

@@ -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 %}

View File

@@ -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">
@@ -26,4 +27,9 @@
{%- 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 %}

View File

@@ -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>
@@ -134,6 +127,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];

View File

@@ -10,29 +10,6 @@
{%- 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>

View File

@@ -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");

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,22 +32,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.")
)]
)

View File

@@ -49,6 +49,8 @@ TAG_DATA_FORM_VALUE = "{{{}}}value".format(NS_DATA_FORM)
FORM_NODE_CONFIG = "http://jabber.org/protocol/pubsub#node_config"
FORM_FIELD_PUBSUB_ACCESS_MODEL = "pubsub#access_model"
NS_VCARD_TEMP = "vcard-temp"
SimpleJID = typing.Tuple[typing.Optional[str], str, typing.Optional[str]]
T = typing.TypeVar("T")
@@ -226,6 +228,35 @@ def make_avatar_metadata_set_request(
return req
def make_muc_avatar_set_request(
to: str,
data: bytes,
mimetype: str,
) -> ET.Element:
req = ET.Element("iq", type="set", to=to)
vcard = ET.SubElement(
req,
"vCard",
xmlns=NS_VCARD_TEMP,
)
photo_el = ET.SubElement(
vcard,
"PHOTO",
xmlns=NS_VCARD_TEMP,
)
ET.SubElement(
photo_el,
"BINVAL",
xmlns=NS_VCARD_TEMP,
).text = base64.b64encode(data).decode("ascii")
ET.SubElement(
photo_el,
"TYPE",
xmlns=NS_VCARD_TEMP,
).text = mimetype
return req
def _require_child(t: ET.Element, tag: str) -> ET.Element:
el = t.find(tag)
if el is None:

View File

@@ -6,8 +6,6 @@ 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

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

@@ -9,9 +9,9 @@ set -euo pipefail
# FLAVOR one of '', 'round', 'sharp', 'outlined', 'twoshade'
# 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 Googles Material Icons set,\nlicensed under the terms of the Apache 2.0 License -->\n' >> "$output_file"