Compare commits
279 Commits
feature/on
...
beta.20211
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e7de3166 | ||
|
|
61c71b2145 | ||
|
|
6b35e9a259 | ||
|
|
58c2112fec | ||
|
|
c856afee82 | ||
|
|
c8356a8e9e | ||
|
|
0eb464f428 | ||
|
|
2a6ef3c8f1 | ||
|
|
b5d148458a | ||
|
|
261758b07a | ||
|
|
ff99c9488a | ||
|
|
fe78631039 | ||
|
|
12ddd288bf | ||
|
|
633fb0d084 | ||
|
|
f9690063bc | ||
|
|
65ed50acd3 | ||
|
|
aa04320d70 | ||
|
|
818d50a1bb | ||
|
|
c7ba7985ea | ||
|
|
223d127364 | ||
|
|
3a2c4543c4 | ||
|
|
c307f057b9 | ||
|
|
243d5ba236 | ||
|
|
3d62efccfc | ||
|
|
9d26e39025 | ||
|
|
874f0447ba | ||
|
|
0f2127a672 | ||
|
|
20d84e7dd1 | ||
|
|
a02e66023c | ||
|
|
e7db9cc772 | ||
|
|
e91fb45374 | ||
|
|
531565d55c | ||
|
|
c6307619f9 | ||
|
|
da2668cbbc | ||
|
|
765e3890b4 | ||
|
|
b40a625283 | ||
|
|
8a293985ca | ||
|
|
13b2a76c3d | ||
|
|
28e01c336d | ||
|
|
5fb0b91178 | ||
|
|
b007afc901 | ||
|
|
7f02746f63 | ||
|
|
f2788aeb36 | ||
|
|
536a05b0eb | ||
|
|
e0226d47e3 | ||
|
|
0fe10a44ce | ||
|
|
e892d81815 | ||
|
|
c58ce8450f | ||
|
|
03573d1f05 | ||
|
|
486596f89f | ||
|
|
425b4d4295 | ||
|
|
87de808046 | ||
|
|
05455ac743 | ||
|
|
1e926714cb | ||
|
|
e1602f3140 | ||
|
|
2e89973263 | ||
|
|
a6f1361ddd | ||
|
|
552a3bbd41 | ||
|
|
3f2de1e5bf | ||
|
|
059a10f475 | ||
|
|
a48abacf1d | ||
|
|
ea7ed7c030 | ||
|
|
cca899bd8c | ||
|
|
359e6b4ce2 | ||
|
|
6650dd2046 | ||
|
|
97b4a7be0f | ||
|
|
329916e200 | ||
|
|
3571b8909b | ||
|
|
c6c01b82f5 | ||
|
|
c4b575f091 | ||
|
|
fdb55568ec | ||
|
|
a9a651be09 | ||
|
|
d2069289b0 | ||
|
|
552b5d2940 | ||
|
|
b0f9ae5d57 | ||
|
|
dd4a012612 | ||
|
|
e7aa0a2c45 | ||
|
|
ad229d6700 | ||
|
|
b822000f2e | ||
|
|
a6b67b3fdd | ||
|
|
885db355ab | ||
|
|
c3d5b06313 | ||
|
|
2dd8838852 | ||
|
|
5df2c3945a | ||
|
|
3eb8036ebd | ||
|
|
02ed390cd2 | ||
|
|
2506810b90 | ||
|
|
05d1b42dc4 | ||
|
|
5ef5b93eb9 | ||
|
|
0ff6e00e9d | ||
|
|
c04ac4bee0 | ||
|
|
3e19d42c2a | ||
|
|
03732ac06b | ||
|
|
c70228fed7 | ||
|
|
025172592f | ||
|
|
6de1e5313f | ||
|
|
3083c118a3 | ||
|
|
fa1b13fbdb | ||
|
|
ba30d728f4 | ||
|
|
af87301fa4 | ||
|
|
8ee0b0dd30 | ||
|
|
4a27ef9d72 | ||
|
|
9e9fdaf8d4 | ||
|
|
bdb186ca81 | ||
|
|
4ca9b82bce | ||
|
|
6dbe2c2d5e | ||
|
|
e410aedfef | ||
|
|
1713da61e7 | ||
|
|
53aac690df | ||
|
|
5e4009ca11 | ||
|
|
80860a3ac6 | ||
|
|
e9d479a78b | ||
|
|
aac56f49e9 | ||
|
|
52f0bee006 | ||
|
|
97c91b432d | ||
|
|
60647159f3 | ||
|
|
a21730f136 | ||
|
|
e35ab1b723 | ||
|
|
4de4509fc9 | ||
|
|
93e3b325b1 | ||
|
|
ceecfc861c | ||
|
|
2467e73781 | ||
|
|
2f34d39a09 | ||
|
|
de8589923b | ||
|
|
db3a1ac22f | ||
|
|
b48d130659 | ||
|
|
1aed573eb2 | ||
|
|
d4707196ec | ||
|
|
8a8d4c54bd | ||
|
|
ab534e3a59 | ||
|
|
4c128f1af2 | ||
|
|
8b551a8946 | ||
|
|
182d2301be | ||
|
|
6dba5e3a65 | ||
|
|
713da89445 | ||
|
|
9876e42fb7 | ||
|
|
8b66c5a063 | ||
|
|
ddf9f89d77 | ||
|
|
53e023f9ae | ||
|
|
e4d339627e | ||
|
|
cd3026911b | ||
|
|
d7da16f780 | ||
|
|
8ed0fbec25 | ||
|
|
5b812c773d | ||
|
|
fa61ee4e11 | ||
|
|
7402480c62 | ||
|
|
a68a469319 | ||
|
|
961f285fa5 | ||
|
|
7456295cb6 | ||
|
|
96f4b0d4f8 | ||
|
|
245434126e | ||
|
|
725dffc458 | ||
|
|
22783b837e | ||
|
|
ba18fe692f | ||
|
|
387a989caa | ||
|
|
ffab48cff0 | ||
|
|
17bf7cb140 | ||
|
|
408d837a0f | ||
|
|
56e1083ada | ||
|
|
2aa3d629da | ||
|
|
6779341db3 | ||
|
|
10a0de0637 | ||
|
|
b3185a8d18 | ||
|
|
2db6cbe6fd | ||
|
|
9bc6e0b555 | ||
|
|
98a3eeba7c | ||
|
|
de97b08f01 | ||
|
|
f2dc970731 | ||
|
|
2f8e724104 | ||
|
|
a3ab537de0 | ||
|
|
b04c4fa42d | ||
|
|
078be4ba35 | ||
|
|
c1f186a3da | ||
|
|
7aaeb0f368 | ||
|
|
b475e76189 | ||
|
|
8b6f5e8e18 | ||
|
|
695ece9165 | ||
|
|
7013161872 | ||
|
|
f19270b276 | ||
|
|
cd4955cce8 | ||
|
|
4d03946a08 | ||
|
|
60f7d063b6 | ||
|
|
a57c2c6e20 | ||
|
|
6a5445a525 | ||
|
|
40a2e6b1b4 | ||
|
|
4b48ebda90 | ||
|
|
09f498c6b3 | ||
|
|
781bac0ffa | ||
|
|
377a28c070 | ||
|
|
0046f2f494 | ||
|
|
7239b8cbdc | ||
|
|
0008d0215e | ||
|
|
2f69e48ef8 | ||
|
|
58a9903a8f | ||
|
|
0dbd8087eb | ||
|
|
ba3440b169 | ||
|
|
8a010cf3a3 | ||
|
|
e7610928e2 | ||
|
|
522b65f8ef | ||
|
|
3c90a8ca79 | ||
|
|
abd7894b6f | ||
|
|
eaaca163a0 | ||
|
|
1c4fa92c97 | ||
|
|
42b5f05c8f | ||
|
|
59701a4af8 | ||
|
|
5f1d3ba307 | ||
|
|
985675e012 | ||
|
|
f372b31b9d | ||
|
|
9612926230 | ||
|
|
57adf0c679 | ||
|
|
c28e98ec18 | ||
|
|
f27c86e29a | ||
|
|
ec94a47c8c | ||
|
|
362587d852 | ||
|
|
25f161d2b0 | ||
|
|
e5c5bbfbb4 | ||
|
|
4044e857bc | ||
|
|
f4348600e2 | ||
|
|
028f9b35a6 | ||
|
|
50d5fd21e0 | ||
|
|
a75bf333bd | ||
|
|
47975cb1a6 | ||
|
|
2ae9425b6a | ||
|
|
00b4d528c9 | ||
|
|
e58a3176ac | ||
|
|
205b0173a7 | ||
|
|
6235231db5 | ||
|
|
af61705482 | ||
|
|
46c7b3be11 | ||
|
|
547286b2e3 | ||
|
|
f68db94d91 | ||
|
|
16da296f79 | ||
|
|
d5ad562d2c | ||
|
|
c2126419d4 | ||
|
|
7028770f40 | ||
|
|
350fd29622 | ||
|
|
a6aef681a7 | ||
|
|
5256872646 | ||
|
|
ae47e5268b | ||
|
|
4b4844ecaa | ||
|
|
0ead8ce4b3 | ||
|
|
d568a235eb | ||
|
|
c3ce7d9f3a | ||
|
|
4d0ba8ef9a | ||
|
|
2f368e0a34 | ||
|
|
ad0041ba84 | ||
|
|
561d576934 | ||
|
|
a642daf77a | ||
|
|
7018f03c34 | ||
|
|
7da56c81fc | ||
|
|
2b7930a5b7 | ||
|
|
d556034349 | ||
|
|
d8341455a2 | ||
|
|
f5c7b9f0e3 | ||
|
|
a86d033f20 | ||
|
|
1fd51b00ed | ||
|
|
95ec9adfcd | ||
|
|
3446f57478 | ||
|
|
dd607af1ae | ||
|
|
ea4bb8d98e | ||
|
|
61687e3158 | ||
|
|
ca977ffec3 | ||
|
|
f539493bf5 | ||
|
|
6ffce26b08 | ||
|
|
a9f9f9d74a | ||
|
|
b338b0a08f | ||
|
|
1a4f16eaef | ||
|
|
2521aa98af | ||
|
|
a6d20a0a73 | ||
|
|
df75fbaa1b | ||
|
|
5f1a45082e | ||
|
|
9e2b6a4115 | ||
|
|
91febde2a3 | ||
|
|
b548dc011d | ||
|
|
88b8f675c1 | ||
|
|
693b1ac23f | ||
|
|
7ec12a5958 | ||
|
|
3a15406771 | ||
|
|
cc27256b14 |
23
.github/workflows/build-portal-image.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Docker image build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build the Docker image
|
||||
run: >-
|
||||
docker build . \
|
||||
--build-arg=BUILD_SERIES=dev \
|
||||
--build-arg=BUILD_ID="$(echo "$GITHUB_SHA" | head -c 12)" \
|
||||
--tag snikket/snikket-web-portal:dev
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" | docker login -u snikket --password-stdin
|
||||
- name: Push the Docker image
|
||||
run: docker push snikket/snikket-web-portal:dev
|
||||
31
.github/workflows/build-portal-release-image.yml
vendored
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: Docker release image build
|
||||
|
||||
"on":
|
||||
push:
|
||||
tags:
|
||||
- release/*.*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build the Docker image
|
||||
run: >-
|
||||
echo "Building ref $GITHUB_REF...";
|
||||
RELEASE_TAG="${GITHUB_REF#refs/tags/release/}";
|
||||
RELEASE_SERIES="${RELEASE_TAG%.*}";
|
||||
RELEASE_VER="${RELEASE_TAG#$RELEASE_SERIES.}";
|
||||
docker build . \
|
||||
--build-arg=BUILD_SERIES="$RELEASE_SERIES" \
|
||||
--build-arg=BUILD_ID="$RELEASE_VER" \
|
||||
--tag snikket/snikket-web-portal:"$RELEASE_SERIES"
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" | docker login -u snikket --password-stdin
|
||||
- name: Push the Docker image
|
||||
run: >-
|
||||
RELEASE_TAG="${GITHUB_REF#refs/tags/release/}";
|
||||
RELEASE_SERIES="${RELEASE_TAG%.*}";
|
||||
docker push snikket/snikket-web-portal:"$RELEASE_SERIES"
|
||||
10
.github/workflows/main.yaml
vendored
@@ -27,6 +27,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
pip install mypy
|
||||
pip install -r requirements.txt
|
||||
pip install -r build-requirements.txt
|
||||
- name: Typecheck
|
||||
run: |
|
||||
python -m mypy --config mypy.ini -p snikket_web
|
||||
@@ -48,3 +49,12 @@ jobs:
|
||||
- name: Linting
|
||||
run: |
|
||||
python -m flake8 snikket_web
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build the Docker image
|
||||
run: >-
|
||||
docker build .
|
||||
|
||||
45
Dockerfile
@@ -1,46 +1,31 @@
|
||||
FROM debian:buster
|
||||
FROM debian:buster-slim
|
||||
|
||||
ARG BUILD_SERIES=dev
|
||||
ARG BUILD_ID=0
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
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
|
||||
|
||||
# This Dockerfile attempts to strike a balance between image size and time it
|
||||
# takes to do an incremental build on changes.
|
||||
# Improvements welcome.
|
||||
WORKDIR /opt/snikket-web-portal
|
||||
|
||||
RUN set -eu; \
|
||||
export DEBIAN_FRONTEND=noninteractive ; \
|
||||
apt-get update ; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-setuptools python3-wheel \
|
||||
libpython3-dev \
|
||||
make build-essential \
|
||||
netcat \
|
||||
; \
|
||||
apt-get clean ; rm -rf /var/lib/apt/lists
|
||||
|
||||
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
|
||||
COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt
|
||||
|
||||
WORKDIR /opt/snikket-web-portal
|
||||
|
||||
RUN set -eu; \
|
||||
pip3 install -r requirements.txt; \
|
||||
pip3 install -r build-requirements.txt; \
|
||||
rm -rf /root/.cache;
|
||||
|
||||
COPY Makefile /opt/snikket-web-portal/Makefile
|
||||
COPY snikket_web/ /opt/snikket-web-portal/snikket_web
|
||||
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
|
||||
|
||||
# NOTE: abusing true(1) as a terrible way to disable a specific command. If
|
||||
# one merged all the RUN commands into one, one would want to run the
|
||||
# uninstall/remove commands there, but with the split up RUN commands it is
|
||||
# rather pointless.
|
||||
RUN set -eu; \
|
||||
make; \
|
||||
true pip3 uninstall -yr build-requirements.txt; \
|
||||
true apt-get remove -y build-essential make libpython3-dev; \
|
||||
true apt-get autoremove -y; \
|
||||
pip3 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
|
||||
@@ -48,5 +33,11 @@ RUN set -eu; \
|
||||
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}
|
||||
|
||||
RUN echo "$BUILD_SERIES $BUILD_ID" > /opt/snikket-web-portal/.app_version
|
||||
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||
|
||||
2
Makefile
@@ -31,7 +31,7 @@ force_update_translations: extract_translations
|
||||
pybabel update -i $(pot_file) -d $(translation_basepath)
|
||||
|
||||
compile_translations:
|
||||
pybabel compile -d $(translation_basepath)
|
||||
-pybabel compile -d $(translation_basepath)
|
||||
|
||||
|
||||
.PHONY: build_css clean update_translations compile_translations extract_translations force_update_translations
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pyscss~=1.3
|
||||
mypy
|
||||
python-dotenv~=0.15
|
||||
types-toml
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
#!/bin/sh
|
||||
exec hypercorn -b "0.0.0.0:8000" 'snikket_web:create_app()'
|
||||
|
||||
export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN"
|
||||
|
||||
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE-127.0.0.1}"
|
||||
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT-5765}"
|
||||
|
||||
exec hypercorn -b "${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE}:${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT}" 'snikket_web:create_app()'
|
||||
|
||||
761
docs/colours.svg
@@ -1,6 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
@@ -9,426 +7,363 @@
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="334mm"
|
||||
height="154mm"
|
||||
viewBox="0 0 334 154"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
|
||||
sodipodi:docname="colours.svg"
|
||||
inkscape:export-filename="/home/horazont/tmp/colours.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4"
|
||||
inkscape:cx="642.84838"
|
||||
inkscape:cy="251.88403"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1401"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="39"
|
||||
inkscape:window-maximized="0"
|
||||
fit-margin-top="5"
|
||||
fit-margin-left="5"
|
||||
fit-margin-right="5"
|
||||
fit-margin-bottom="5" />
|
||||
id="svg1424"
|
||||
sodipodi:docname="out.svg"
|
||||
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
|
||||
<metadata
|
||||
id="metadata5">
|
||||
id="metadata1430">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(65,-148)">
|
||||
<rect
|
||||
style="opacity:1;fill:#418fc7;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect4759"
|
||||
width="24"
|
||||
height="24"
|
||||
x="120"
|
||||
y="273" />
|
||||
<rect
|
||||
y="273"
|
||||
x="-2.7815501e-08"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9362"
|
||||
style="opacity:1;fill:#062943;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="273"
|
||||
x="240"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9364"
|
||||
style="opacity:1;fill:#e4f3fd;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="273"
|
||||
x="60"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9366"
|
||||
style="opacity:1;fill:#0e4c76;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#9dccf0;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9368"
|
||||
width="24"
|
||||
height="24"
|
||||
x="180"
|
||||
y="273" />
|
||||
<rect
|
||||
style="opacity:1;fill:#0f3d62;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9370"
|
||||
width="24"
|
||||
height="24"
|
||||
x="30"
|
||||
y="273" />
|
||||
<rect
|
||||
y="273"
|
||||
x="90"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9372"
|
||||
style="opacity:1;fill:#226494;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="273"
|
||||
x="150"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9376"
|
||||
style="opacity:1;fill:#72b2e3;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="273"
|
||||
x="210"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9378"
|
||||
style="opacity:1;fill:#b5d8f3;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="243"
|
||||
x="120"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9380"
|
||||
style="opacity:1;fill:#c95e40;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#340e03;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9382"
|
||||
width="24"
|
||||
height="24"
|
||||
x="0"
|
||||
y="243" />
|
||||
<rect
|
||||
style="opacity:1;fill:#fef1ed;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9384"
|
||||
width="24"
|
||||
height="24"
|
||||
x="240"
|
||||
y="243" />
|
||||
<rect
|
||||
style="opacity:1;fill:#e2b00c;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9436"
|
||||
width="24"
|
||||
height="24"
|
||||
x="120"
|
||||
y="213" />
|
||||
<rect
|
||||
style="opacity:1;fill:#f2ac99;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9454"
|
||||
width="24"
|
||||
height="24"
|
||||
x="180"
|
||||
y="243" />
|
||||
<rect
|
||||
y="243"
|
||||
x="210"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9456"
|
||||
style="opacity:1;fill:#fbc2b3;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="243"
|
||||
x="150"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9458"
|
||||
style="opacity:1;fill:#ed947c;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#883017;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9460"
|
||||
width="24"
|
||||
height="24"
|
||||
x="60"
|
||||
y="243" />
|
||||
<rect
|
||||
y="243"
|
||||
x="30"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9462"
|
||||
style="opacity:1;fill:#681f0b;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="243"
|
||||
x="90"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9464"
|
||||
style="opacity:1;fill:#a33d21;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="183"
|
||||
x="120"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9466"
|
||||
style="opacity:1;fill:#55c644;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="213"
|
||||
x="240"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9484"
|
||||
style="opacity:1;fill:#fffcf0;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="213"
|
||||
x="0"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9486"
|
||||
style="opacity:1;fill:#302100;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="213"
|
||||
x="60"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9488"
|
||||
style="opacity:1;fill:#886600;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#563600;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9490"
|
||||
width="24"
|
||||
height="24"
|
||||
x="30"
|
||||
y="213" />
|
||||
<rect
|
||||
y="213"
|
||||
x="90"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9494"
|
||||
style="opacity:1;fill:#b98601;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="213"
|
||||
x="180"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9496"
|
||||
style="opacity:1;fill:#feed93;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="213"
|
||||
x="150"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9500"
|
||||
style="opacity:1;fill:#fde58a;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#fff7c2;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9502"
|
||||
width="24"
|
||||
height="24"
|
||||
x="210"
|
||||
y="213" />
|
||||
<rect
|
||||
style="opacity:1;fill:#8f8983;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9504"
|
||||
width="24"
|
||||
height="24"
|
||||
x="120"
|
||||
y="153" />
|
||||
<rect
|
||||
y="153"
|
||||
x="0"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9506"
|
||||
style="opacity:1;fill:#1f1b17;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="153"
|
||||
x="240"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9508"
|
||||
style="opacity:1;fill:#f6f5f4;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="153"
|
||||
x="180"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9510"
|
||||
style="opacity:1;fill:#cac3bd;fill-opacity:0.98872178;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#e3e1df;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9512"
|
||||
width="24"
|
||||
height="24"
|
||||
x="210"
|
||||
y="153" />
|
||||
<rect
|
||||
style="opacity:1;fill:#4e4a46;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9514"
|
||||
width="24"
|
||||
height="24"
|
||||
x="60"
|
||||
y="153" />
|
||||
<rect
|
||||
y="153"
|
||||
x="30"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9516"
|
||||
style="opacity:1;fill:#3d3833;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#706965;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9518"
|
||||
width="24"
|
||||
height="24"
|
||||
x="90"
|
||||
y="153" />
|
||||
<rect
|
||||
y="153"
|
||||
x="150"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9520"
|
||||
style="opacity:1;fill:#b1aca6;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#ecfbe6;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9522"
|
||||
width="24"
|
||||
height="24"
|
||||
x="240"
|
||||
y="183" />
|
||||
<rect
|
||||
style="opacity:1;fill:#052f03;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9524"
|
||||
width="24"
|
||||
height="24"
|
||||
x="0"
|
||||
y="183" />
|
||||
<rect
|
||||
style="opacity:1;fill:#197713;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9526"
|
||||
width="24"
|
||||
height="24"
|
||||
x="60"
|
||||
y="183" />
|
||||
<rect
|
||||
y="183"
|
||||
x="30"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9528"
|
||||
style="opacity:1;fill:#0c4608;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
y="183"
|
||||
x="90"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9530"
|
||||
style="opacity:1;fill:#218a1b;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="opacity:1;fill:#abed9c;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9532"
|
||||
width="24"
|
||||
height="24"
|
||||
x="180"
|
||||
y="183" />
|
||||
<rect
|
||||
style="opacity:1;fill:#81e06e;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect9536"
|
||||
width="24"
|
||||
height="24"
|
||||
x="150"
|
||||
y="183" />
|
||||
<rect
|
||||
y="183"
|
||||
x="210"
|
||||
height="24"
|
||||
width="24"
|
||||
id="rect9538"
|
||||
style="opacity:1;fill:#cef6c5;fill-opacity:1;stroke:none;stroke-width:0.32483485;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12.69999981px;line-height:1em;font-family:'Liberation Sans';-inkscape-font-specification:'Liberation Sans';text-align:end;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="-3.4391122"
|
||||
y="170.58151"
|
||||
id="text9546"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan9544"
|
||||
x="-3.4391129"
|
||||
y="170.58151"
|
||||
style="stroke-width:0.26458332px">Grayscale</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12.69999981px;line-height:1em;font-family:'Liberation Sans';-inkscape-font-specification:'Liberation Sans';text-align:end;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="-12.554834"
|
||||
y="200.5157"
|
||||
id="text9550"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan9548"
|
||||
x="-12.554835"
|
||||
y="200.5157"
|
||||
style="stroke-width:0.26458332px">Success</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12.69999981px;line-height:1em;font-family:'Liberation Sans';-inkscape-font-specification:'Liberation Sans';text-align:end;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="-21.180664"
|
||||
y="230.71715"
|
||||
id="text9554"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan9552"
|
||||
x="-21.180664"
|
||||
y="230.71715"
|
||||
style="stroke-width:0.26458332px">Accent</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12.69999981px;line-height:1em;font-family:'Liberation Sans';-inkscape-font-specification:'Liberation Sans';text-align:end;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="-33.880665"
|
||||
y="261.45316"
|
||||
id="text9558"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan9556"
|
||||
x="-33.880665"
|
||||
y="261.45316"
|
||||
style="stroke-width:0.26458332px">Alert</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12.69999981px;line-height:1em;font-family:'Liberation Sans';-inkscape-font-specification:'Liberation Sans';text-align:end;letter-spacing:0px;word-spacing:0px;text-anchor:end;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="-17.236719"
|
||||
y="290.85278"
|
||||
id="text9562"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan9560"
|
||||
x="-17.236719"
|
||||
y="290.85278"
|
||||
style="stroke-width:0.26458332px">Primary</tspan></text>
|
||||
</g>
|
||||
<defs
|
||||
id="defs1428" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1401"
|
||||
id="namedview1426"
|
||||
showgrid="false"
|
||||
inkscape:zoom="3.9666667"
|
||||
inkscape:cx="123.61033"
|
||||
inkscape:cy="67.707413"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="39"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg1424" />
|
||||
<rect
|
||||
style="fill: #000000;"
|
||||
width="310"
|
||||
height="240"
|
||||
x="-25"
|
||||
y="-25"
|
||||
id="rect1332" />
|
||||
<rect
|
||||
style="fill: #1f1b17;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="0"
|
||||
y="0"
|
||||
id="rect1334" />
|
||||
<rect
|
||||
style="fill: #3d3833;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="30"
|
||||
y="0"
|
||||
id="rect1336" />
|
||||
<rect
|
||||
style="fill: #4e4a46;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="60"
|
||||
y="0"
|
||||
id="rect1338" />
|
||||
<rect
|
||||
style="fill: #706965;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="90"
|
||||
y="0"
|
||||
id="rect1340" />
|
||||
<rect
|
||||
style="fill: #8f8983;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="120"
|
||||
y="0"
|
||||
id="rect1342" />
|
||||
<rect
|
||||
style="fill: #b1aca6;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="150"
|
||||
y="0"
|
||||
id="rect1344" />
|
||||
<rect
|
||||
style="fill: #cac3bd;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="180"
|
||||
y="0"
|
||||
id="rect1346" />
|
||||
<rect
|
||||
style="fill: #e3e1df;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="210"
|
||||
y="0"
|
||||
id="rect1348" />
|
||||
<rect
|
||||
style="fill: #f6f5f4;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="240"
|
||||
y="0"
|
||||
id="rect1350" />
|
||||
<rect
|
||||
style="fill:#062243;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="0"
|
||||
y="40"
|
||||
id="rect1352" />
|
||||
<rect
|
||||
style="fill:#0f3462;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="30"
|
||||
y="40"
|
||||
id="rect1354" />
|
||||
<rect
|
||||
style="fill:#0e4276;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="60"
|
||||
y="40"
|
||||
id="rect1356" />
|
||||
<rect
|
||||
style="fill:#225994;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="90"
|
||||
y="40"
|
||||
id="rect1358" />
|
||||
<rect
|
||||
style="fill:#4182c7;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="120"
|
||||
y="40"
|
||||
id="rect1360" />
|
||||
<rect
|
||||
style="fill:#72a7e3;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="150"
|
||||
y="40"
|
||||
id="rect1362" />
|
||||
<rect
|
||||
style="fill:#9dc4f0;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="180"
|
||||
y="40"
|
||||
id="rect1364" />
|
||||
<rect
|
||||
style="fill:#b5d2f3;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="210"
|
||||
y="40"
|
||||
id="rect1366" />
|
||||
<rect
|
||||
style="fill:#e4f0fd;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="240"
|
||||
y="40"
|
||||
id="rect1368" />
|
||||
<rect
|
||||
style="fill: #340e03;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="0"
|
||||
y="80"
|
||||
id="rect1370" />
|
||||
<rect
|
||||
style="fill: #681f0b;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="30"
|
||||
y="80"
|
||||
id="rect1372" />
|
||||
<rect
|
||||
style="fill: #883017;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="60"
|
||||
y="80"
|
||||
id="rect1374" />
|
||||
<rect
|
||||
style="fill: #a33d21;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="90"
|
||||
y="80"
|
||||
id="rect1376" />
|
||||
<rect
|
||||
style="fill: #c95e40;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="120"
|
||||
y="80"
|
||||
id="rect1378" />
|
||||
<rect
|
||||
style="fill: #ed947c;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="150"
|
||||
y="80"
|
||||
id="rect1380" />
|
||||
<rect
|
||||
style="fill: #f2ac99;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="180"
|
||||
y="80"
|
||||
id="rect1382" />
|
||||
<rect
|
||||
style="fill: #fbc2b3;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="210"
|
||||
y="80"
|
||||
id="rect1384" />
|
||||
<rect
|
||||
style="fill: #fef1ed;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="240"
|
||||
y="80"
|
||||
id="rect1386" />
|
||||
<rect
|
||||
style="fill: #302100;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="0"
|
||||
y="120"
|
||||
id="rect1388" />
|
||||
<rect
|
||||
style="fill: #563600;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="30"
|
||||
y="120"
|
||||
id="rect1390" />
|
||||
<rect
|
||||
style="fill: #795b00;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="60"
|
||||
y="120"
|
||||
id="rect1392" />
|
||||
<rect
|
||||
style="fill: #a07501;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="90"
|
||||
y="120"
|
||||
id="rect1394" />
|
||||
<rect
|
||||
style="fill: #c79b0e;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="120"
|
||||
y="120"
|
||||
id="rect1396" />
|
||||
<rect
|
||||
style="fill: #f4ce3f;"
|
||||
width="20"
|
||||
height="20"
|
||||
x="150"
|
||||
y="120"
|
||||
id="rect1398" />
|
||||
<rect
|
||||
style="fill:#fee577;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="180"
|
||||
y="120"
|
||||
id="rect1400" />
|
||||
<rect
|
||||
style="fill:#fef1c1;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="210"
|
||||
y="120"
|
||||
id="rect1402" />
|
||||
<rect
|
||||
style="fill:#fff8e8;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="240"
|
||||
y="120"
|
||||
id="rect1404" />
|
||||
<rect
|
||||
style="fill:#172f03;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="0"
|
||||
y="160"
|
||||
id="rect1406" />
|
||||
<rect
|
||||
style="fill:#244608;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="30"
|
||||
y="160"
|
||||
id="rect1408" />
|
||||
<rect
|
||||
style="fill:#407713;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="60"
|
||||
y="160"
|
||||
id="rect1410" />
|
||||
<rect
|
||||
style="fill:#548f19;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="90"
|
||||
y="160"
|
||||
id="rect1412" />
|
||||
<rect
|
||||
style="fill:#7fc644;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="120"
|
||||
y="160"
|
||||
id="rect1414" />
|
||||
<rect
|
||||
style="fill:#a1e06e;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="150"
|
||||
y="160"
|
||||
id="rect1416" />
|
||||
<rect
|
||||
style="fill:#c0ed9c;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="180"
|
||||
y="160"
|
||||
id="rect1418" />
|
||||
<rect
|
||||
style="fill:#dbf6c5;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="210"
|
||||
y="160"
|
||||
id="rect1420" />
|
||||
<rect
|
||||
style="fill:#effbe6;fill-opacity:1"
|
||||
width="20"
|
||||
height="20"
|
||||
x="240"
|
||||
y="160"
|
||||
id="rect1422" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 421 KiB |
@@ -1,8 +1,9 @@
|
||||
aiohttp~=3.6
|
||||
quart~=0.11
|
||||
quart~=0.11,<0.15
|
||||
flask-wtf~=0.14
|
||||
hsluv~=0.0.2
|
||||
flask-babel~=1.0
|
||||
email-validator~=1.1
|
||||
environ-config~=20.0
|
||||
wtforms~=2.3
|
||||
typing-extensions
|
||||
|
||||
@@ -15,12 +15,13 @@ from quart import (
|
||||
render_template,
|
||||
current_app,
|
||||
redirect,
|
||||
jsonify,
|
||||
)
|
||||
|
||||
import environ
|
||||
|
||||
from . import colour, infra
|
||||
from ._version import version, version_info # noqa:F401
|
||||
from ._version import version # noqa:F401
|
||||
|
||||
|
||||
async def proc() -> typing.Dict[str, typing.Any]:
|
||||
@@ -47,6 +48,7 @@ async def proc() -> typing.Dict[str, typing.Any]:
|
||||
"text_to_css": colour.text_to_css,
|
||||
"lang": infra.selected_locale(),
|
||||
"user_info": user_info,
|
||||
"is_in_debug_mode": current_app.debug,
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +144,25 @@ class AppConfig:
|
||||
domain = environ.var()
|
||||
site_name = environ.var("")
|
||||
avatar_cache_ttl = environ.var(1800, converter=int)
|
||||
languages = environ.var(["de", "en"], converter=autosplit)
|
||||
languages = environ.var([
|
||||
"da",
|
||||
"de",
|
||||
"en",
|
||||
"fr",
|
||||
"id",
|
||||
"it",
|
||||
"pl",
|
||||
"sv",
|
||||
], converter=autosplit)
|
||||
apple_store_url = environ.var(
|
||||
"https://apps.apple.com/us/app/snikket/id1545164189",
|
||||
)
|
||||
# Default limit of 1 MiB is what was discovered to be the effective limit
|
||||
# in #67, hence we set that here for now.
|
||||
# Future versions may change this default, and the standard deployment
|
||||
# tools may also very well override it.
|
||||
max_avatar_size = environ.var(1024*1024, converter=int)
|
||||
show_metrics = environ.bool_var(True)
|
||||
|
||||
|
||||
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
|
||||
@@ -155,7 +175,7 @@ def create_app() -> quart.Quart:
|
||||
pass
|
||||
else:
|
||||
import runpy
|
||||
init_vars = runpy.run_path(env_init) # type:ignore
|
||||
init_vars = runpy.run_path(env_init)
|
||||
for name, value in init_vars.items():
|
||||
if not name:
|
||||
continue
|
||||
@@ -172,6 +192,9 @@ def create_app() -> quart.Quart:
|
||||
app.config["SNIKKET_DOMAIN"] = config.domain
|
||||
app.config["SITE_NAME"] = config.site_name or config.domain
|
||||
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
|
||||
app.config["APPLE_STORE_URL"] = config.apple_store_url
|
||||
app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size
|
||||
app.config["SHOW_METRICS"] = config.show_metrics
|
||||
|
||||
app.context_processor(proc)
|
||||
app.register_error_handler(
|
||||
@@ -194,6 +217,44 @@ def create_app() -> quart.Quart:
|
||||
|
||||
return redirect(url_for('main.login'))
|
||||
|
||||
@app.route("/site.webmanifest")
|
||||
def site_manifest() -> quart.Response:
|
||||
# this is needed for icons
|
||||
return jsonify(
|
||||
{
|
||||
"name": "Snikket",
|
||||
"short_name": "Snikket",
|
||||
"icons": [
|
||||
{
|
||||
"src": url_for(
|
||||
"static",
|
||||
filename="img/android-chrome-192x192.png",
|
||||
),
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": url_for(
|
||||
"static",
|
||||
filename="img/android-chrome-256x256.png",
|
||||
),
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": url_for(
|
||||
"static",
|
||||
filename="img/android-chrome-512x512.png",
|
||||
),
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
],
|
||||
"theme_color": "#fbfdff",
|
||||
"background_color": "#fbfdff",
|
||||
}
|
||||
)
|
||||
|
||||
logging_config = app.config.get("LOGGING_CONFIG")
|
||||
if logging_config is not None:
|
||||
if isinstance(logging_config, dict):
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
version_info = (0, 1, 0, "a0")
|
||||
version = (
|
||||
".".join(map(str, version_info[:3])) +
|
||||
(f"-{version_info[3]}" if version_info[3] else "")
|
||||
)
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
version = "(unknown)"
|
||||
|
||||
if os.path.exists(".app_version"):
|
||||
with open(".app_version") as f:
|
||||
version = f.read().strip()
|
||||
elif os.path.exists(".git"):
|
||||
try:
|
||||
version = subprocess.check_output([
|
||||
"git", "describe", "--always"
|
||||
]).strip().decode("utf8")
|
||||
except OSError:
|
||||
version = "dev (unknown)"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import resource
|
||||
import time
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
@@ -18,13 +19,14 @@ from quart import (
|
||||
url_for,
|
||||
request,
|
||||
abort,
|
||||
flash,
|
||||
current_app,
|
||||
)
|
||||
import flask_wtf
|
||||
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from flask_babel import lazy_gettext as _l, _
|
||||
|
||||
from . import prosodyclient
|
||||
from .infra import client, circle_name
|
||||
from . import prosodyclient, _version
|
||||
from .infra import client, circle_name, BaseForm
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
@@ -32,11 +34,14 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@bp.route("/")
|
||||
@client.require_admin_session()
|
||||
async def index() -> str:
|
||||
return await render_template("admin_home.html")
|
||||
show_metrics = current_app.config["SHOW_METRICS"]
|
||||
return await render_template(
|
||||
"admin_home.html",
|
||||
show_metrics=show_metrics,
|
||||
)
|
||||
|
||||
|
||||
class PasswordResetLinkPost(flask_wtf.FlaskForm): # type: ignore
|
||||
action_create = wtforms.StringField()
|
||||
class PasswordResetLinkPost(BaseForm):
|
||||
action_revoke = wtforms.StringField()
|
||||
|
||||
|
||||
@@ -47,15 +52,94 @@ async def users() -> str:
|
||||
await client.list_users(),
|
||||
key=lambda x: x.localpart
|
||||
)
|
||||
invite_form = InvitePost()
|
||||
await invite_form.init_choices()
|
||||
reset_form = PasswordResetLinkPost()
|
||||
return await render_template(
|
||||
"admin_users.html",
|
||||
users=users,
|
||||
reset_form=reset_form,
|
||||
invite_form=invite_form,
|
||||
)
|
||||
|
||||
|
||||
class DeleteUserForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class EditUserForm(BaseForm):
|
||||
localpart = wtforms.StringField(
|
||||
_l("Login name"),
|
||||
)
|
||||
|
||||
display_name = wtforms.StringField(
|
||||
_l("Display name"),
|
||||
)
|
||||
|
||||
role = wtforms.RadioField(
|
||||
_l("Access Level"),
|
||||
choices=[
|
||||
("prosody:restricted", _("Limited")),
|
||||
("prosody:normal", _l("Normal user")),
|
||||
("prosody:admin", _l("Administrator")),
|
||||
],
|
||||
)
|
||||
|
||||
action_save = wtforms.SubmitField(
|
||||
_l("Update user"),
|
||||
)
|
||||
|
||||
action_create_reset = wtforms.SubmitField(
|
||||
_l("Create password reset link"),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/user/<localpart>/", methods=["GET", "POST"])
|
||||
@client.require_admin_session()
|
||||
async def edit_user(localpart: str) -> typing.Union[quart.Response, str]:
|
||||
target_user_info = await client.get_user_by_localpart(localpart)
|
||||
|
||||
form = EditUserForm()
|
||||
if form.validate_on_submit():
|
||||
if form.action_create_reset.data:
|
||||
target_user_info = await client.get_user_by_localpart(localpart)
|
||||
reset_link = await client.create_password_reset_invite(
|
||||
localpart=localpart,
|
||||
ttl=86400,
|
||||
)
|
||||
await flash(
|
||||
_("Password reset link created"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(
|
||||
".user_password_reset_link",
|
||||
id_=reset_link.id_,
|
||||
))
|
||||
|
||||
await client.update_user(
|
||||
localpart,
|
||||
display_name=form.display_name.data,
|
||||
roles=[form.role.data],
|
||||
)
|
||||
|
||||
await flash(
|
||||
_("User information updated."),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_user", localpart=localpart))
|
||||
|
||||
elif request.method == "GET":
|
||||
form.localpart.data = target_user_info.localpart
|
||||
form.display_name.data = target_user_info.display_name
|
||||
if target_user_info.roles:
|
||||
form.role.data = target_user_info.roles[0]
|
||||
else:
|
||||
form.role.data = "prosody:normal"
|
||||
|
||||
return await render_template(
|
||||
"admin_edit_user.html",
|
||||
target_user=target_user_info,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
class DeleteUserForm(BaseForm):
|
||||
action_delete = wtforms.SubmitField(
|
||||
_l("Delete user permanently")
|
||||
)
|
||||
@@ -69,6 +153,10 @@ async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
|
||||
if form.validate_on_submit():
|
||||
if form.action_delete.data:
|
||||
await client.delete_user_by_localpart(localpart)
|
||||
await flash(
|
||||
_("User deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
return await render_template(
|
||||
@@ -94,37 +182,47 @@ async def debug_user(localpart: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/users/password-reset/-", methods=["POST"])
|
||||
@bp.route("/users/password-reset/<id_>", methods=["GET", "POST"])
|
||||
@client.require_admin_session()
|
||||
async def create_password_reset_link() -> typing.Union[str, quart.Response]:
|
||||
form = PasswordResetLinkPost()
|
||||
if not form.validate_on_submit():
|
||||
abort(400)
|
||||
|
||||
if form.action_create.data:
|
||||
localpart = form.action_create.data
|
||||
target_user_info = await client.get_user_by_localpart(localpart)
|
||||
reset_link = await client.create_password_reset_invite(
|
||||
localpart=localpart,
|
||||
ttl=86400,
|
||||
async def user_password_reset_link(
|
||||
id_: str,
|
||||
) -> typing.Union[str, quart.Response]:
|
||||
invite_info = await client.get_invite_by_id(
|
||||
id_,
|
||||
)
|
||||
if invite_info.jid is None:
|
||||
await flash(
|
||||
_("Password reset link not found"),
|
||||
"alert",
|
||||
)
|
||||
elif form.action_revoke.data:
|
||||
await client.delete_invite(form.action_revoke.data)
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
localpart = prosodyclient.split_jid(invite_info.jid)[0]
|
||||
|
||||
form = PasswordResetLinkPost()
|
||||
if form.validate_on_submit():
|
||||
if form.action_revoke.data:
|
||||
await client.delete_invite(id_)
|
||||
await flash(
|
||||
_("Password reset link deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_user", localpart=localpart))
|
||||
abort(400)
|
||||
|
||||
return await render_template(
|
||||
"admin_reset_user_password.html",
|
||||
target_user=target_user_info,
|
||||
reset_link=reset_link,
|
||||
localpart=localpart,
|
||||
reset_link=invite_info,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
class InvitesListForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class InvitesListForm(BaseForm):
|
||||
action_revoke = wtforms.StringField()
|
||||
|
||||
|
||||
class InvitePost(flask_wtf.FlaskForm): # type:ignore
|
||||
class InvitePost(BaseForm):
|
||||
circles = wtforms.SelectMultipleField(
|
||||
_l("Invite to circle"),
|
||||
# NOTE: This is for when/if we ever support multi-group invites.
|
||||
@@ -148,8 +246,13 @@ class InvitePost(flask_wtf.FlaskForm): # type:ignore
|
||||
default=7*86400,
|
||||
)
|
||||
|
||||
reusable = wtforms.BooleanField(
|
||||
_l("Invite a group of people"),
|
||||
type_ = wtforms.RadioField(
|
||||
_l("Invitation type"),
|
||||
choices=[
|
||||
("account", _l("Individual")),
|
||||
("group", _l("Group")),
|
||||
],
|
||||
default="account",
|
||||
)
|
||||
|
||||
action_create_invite = wtforms.SubmitField(
|
||||
@@ -213,7 +316,7 @@ async def invitations() -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class InviteForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class InviteForm(BaseForm):
|
||||
action_revoke = wtforms.SubmitField(
|
||||
_l("Revoke")
|
||||
)
|
||||
@@ -228,7 +331,7 @@ async def create_invite() -> typing.Union[str, quart.Response]:
|
||||
(c.id_, c.name) for c in circles
|
||||
]
|
||||
if form.validate_on_submit():
|
||||
if form.reusable.data:
|
||||
if form.type_.data == "group":
|
||||
invite = await client.create_group_invite(
|
||||
group_ids=form.circles.data,
|
||||
ttl=form.lifetime.data,
|
||||
@@ -238,6 +341,10 @@ async def create_invite() -> typing.Union[str, quart.Response]:
|
||||
group_ids=form.circles.data,
|
||||
ttl=form.lifetime.data,
|
||||
)
|
||||
await flash(
|
||||
_("Invitation created"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_invite", id_=invite.id_))
|
||||
return await render_template("admin_create_invite.html",
|
||||
invite_form=form)
|
||||
@@ -246,7 +353,15 @@ async def create_invite() -> typing.Union[str, quart.Response]:
|
||||
@bp.route("/invitation/<id_>", methods=["GET", "POST"])
|
||||
@client.require_admin_session()
|
||||
async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
|
||||
invite_info = await client.get_invite_by_id(id_)
|
||||
try:
|
||||
invite_info = await client.get_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
await flash(
|
||||
_("No such invitation exists"),
|
||||
"alert",
|
||||
)
|
||||
return redirect(url_for(".invitations"))
|
||||
circles = await client.list_groups()
|
||||
circle_map = {
|
||||
circle.id_: circle
|
||||
@@ -257,6 +372,10 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
|
||||
if form.validate_on_submit():
|
||||
if form.action_revoke.data:
|
||||
await client.delete_invite(id_)
|
||||
await flash(
|
||||
_("Invitation revoked"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".invitations"))
|
||||
return redirect(url_for(".edit_invite", id_=id_))
|
||||
|
||||
@@ -269,7 +388,7 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class CirclePost(flask_wtf.FlaskForm): # type:ignore
|
||||
class CirclePost(BaseForm):
|
||||
name = wtforms.StringField(
|
||||
_l("Name"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
@@ -305,6 +424,10 @@ async def create_circle() -> typing.Union[str, quart.Response]:
|
||||
circle = await client.create_group(
|
||||
name=create_form.name.data,
|
||||
)
|
||||
await flash(
|
||||
_("Circle created"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".edit_circle", id_=circle.id_))
|
||||
|
||||
return await render_template(
|
||||
@@ -313,7 +436,7 @@ async def create_circle() -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class EditCircleForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class EditCircleForm(BaseForm):
|
||||
name = wtforms.StringField(
|
||||
_l("Name"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
@@ -325,7 +448,7 @@ class EditCircleForm(flask_wtf.FlaskForm): # type:ignore
|
||||
)
|
||||
|
||||
action_save = wtforms.SubmitField(
|
||||
_l("Apply")
|
||||
_l("Update circle")
|
||||
)
|
||||
|
||||
action_delete = wtforms.SubmitField(
|
||||
@@ -350,27 +473,27 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
await flash(
|
||||
_("No such circle exists"),
|
||||
"alert",
|
||||
)
|
||||
return redirect(url_for(".circles"))
|
||||
raise
|
||||
|
||||
circle_members = await asyncio.gather(*(
|
||||
client.get_user_by_localpart(
|
||||
localpart,
|
||||
session=session,
|
||||
)
|
||||
users = {
|
||||
user.localpart: user
|
||||
for user in await client.list_users()
|
||||
}
|
||||
circle_members = [
|
||||
(localpart, users.get(localpart))
|
||||
for localpart in sorted(circle.members)
|
||||
))
|
||||
|
||||
users = await client.list_users()
|
||||
]
|
||||
|
||||
form = EditCircleForm()
|
||||
form.user_to_add.choices = sorted(
|
||||
(
|
||||
(u.localpart, u.localpart)
|
||||
for u in users
|
||||
if u.localpart not in circle.members
|
||||
),
|
||||
key=lambda x: x[1]
|
||||
(localpart, localpart)
|
||||
for localpart in users.keys()
|
||||
if localpart not in circle.members
|
||||
)
|
||||
valid_users = [x[0] for x in form.user_to_add.choices]
|
||||
|
||||
@@ -387,21 +510,36 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
|
||||
id_,
|
||||
new_name=form.name.data,
|
||||
)
|
||||
await flash(
|
||||
_("Circle data updated"),
|
||||
"success",
|
||||
)
|
||||
elif form.action_delete.data:
|
||||
await client.delete_group(id_)
|
||||
await flash(
|
||||
_("Circle deleted"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".circles"))
|
||||
elif form.action_add_user.data:
|
||||
if form.user_to_add.data in valid_users:
|
||||
print("is valid")
|
||||
await client.add_group_member(
|
||||
id_,
|
||||
form.user_to_add.data,
|
||||
)
|
||||
await flash(
|
||||
_("User added to circle"),
|
||||
"success",
|
||||
)
|
||||
elif form.action_remove_user.data:
|
||||
await client.remove_group_member(
|
||||
id_,
|
||||
form.action_remove_user.data,
|
||||
)
|
||||
await flash(
|
||||
_("User removed from circle"),
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for(".edit_circle", id_=id_))
|
||||
else:
|
||||
@@ -414,3 +552,148 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
|
||||
circle_members=circle_members,
|
||||
invite_form=invite_form,
|
||||
)
|
||||
|
||||
|
||||
_CPU_EPOCH = time.process_time()
|
||||
_MONOTONIC_EPOCH = time.monotonic()
|
||||
|
||||
|
||||
def get_system_stats() -> typing.MutableMapping[
|
||||
str,
|
||||
typing.Optional[typing.Union[int, float]]]:
|
||||
pagesize = resource.getpagesize()
|
||||
my_rss: typing.Optional[int] = None
|
||||
try:
|
||||
with open("/proc/self/statm") as f:
|
||||
stats = f.read().split()
|
||||
my_rss = int(stats[1]) * pagesize
|
||||
except (ValueError, IndexError, TypeError, OSError):
|
||||
pass
|
||||
|
||||
my_cpu = (
|
||||
(time.process_time() - _CPU_EPOCH) /
|
||||
(time.monotonic() - _MONOTONIC_EPOCH)
|
||||
)
|
||||
|
||||
mem_total, mem_available = None, None
|
||||
load5: typing.Optional[float] = None
|
||||
|
||||
try:
|
||||
with open("/proc/loadavg") as f:
|
||||
stats = f.read().split()
|
||||
load5 = float(stats[1])
|
||||
except (ValueError, IndexError, TypeError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemTotal"):
|
||||
mem_total = int(line.split()[1]) * 1024
|
||||
elif line.startswith("MemAvailable"):
|
||||
mem_available = int(line.split()[1]) * 1024
|
||||
if mem_total is not None and mem_available is not None:
|
||||
break
|
||||
except (ValueError, TypeError, IndexError, OSError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"portal_rss": my_rss,
|
||||
"portal_cpu": my_cpu,
|
||||
"load5": load5,
|
||||
"mem_total": mem_total,
|
||||
"mem_available": mem_available,
|
||||
}
|
||||
|
||||
|
||||
class AnnouncementForm(BaseForm):
|
||||
text = wtforms.StringField(
|
||||
_("Message contents"),
|
||||
widget=wtforms.widgets.TextArea(),
|
||||
validators=[wtforms.validators.DataRequired()],
|
||||
)
|
||||
|
||||
online_only = wtforms.BooleanField(
|
||||
_("Only send to online users"),
|
||||
)
|
||||
|
||||
action_post_all = wtforms.SubmitField(
|
||||
_("Post to all users"),
|
||||
)
|
||||
|
||||
action_send_preview = wtforms.SubmitField(
|
||||
_("Send preview to yourself"),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/system/", methods=["GET", "POST"])
|
||||
@client.require_admin_session()
|
||||
async def system() -> typing.Union[str, quart.Response]:
|
||||
form = AnnouncementForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
recipients = "self"
|
||||
if form.action_post_all.data:
|
||||
if form.online_only.data:
|
||||
recipients = "online"
|
||||
else:
|
||||
recipients = "all"
|
||||
|
||||
await client.post_announcement(
|
||||
form.text.data,
|
||||
recipients=recipients,
|
||||
)
|
||||
await flash(
|
||||
_("Announcement sent!"),
|
||||
"success",
|
||||
)
|
||||
if recipients != "self":
|
||||
# redirect only if not previewing
|
||||
return redirect(url_for(".system"))
|
||||
|
||||
version = None
|
||||
now = None
|
||||
show_metrics = current_app.config["SHOW_METRICS"]
|
||||
if show_metrics:
|
||||
version = await client.get_server_version()
|
||||
now = time.time()
|
||||
try:
|
||||
prosody_metrics = await client.get_system_metrics()
|
||||
except quart.exceptions.NotFound:
|
||||
# server does not offer the endpoint for whatever reason -- ignore
|
||||
prosody_metrics = {}
|
||||
|
||||
metrics = get_system_stats()
|
||||
try:
|
||||
prosody_cpu_metrics = prosody_metrics["cpu"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
metrics["prosody_cpu"] = (prosody_cpu_metrics["value"] /
|
||||
(now - prosody_cpu_metrics["since"]))
|
||||
|
||||
try:
|
||||
metrics["prosody_rss"] = prosody_metrics["memory"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
metrics["prosody_devices"] = prosody_metrics["c2s"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for k in list(metrics.keys()):
|
||||
if metrics[k] is None:
|
||||
# so that defaulting in jinja works
|
||||
del metrics[k]
|
||||
else:
|
||||
metrics = {}
|
||||
|
||||
return await render_template(
|
||||
"admin_system.html",
|
||||
metrics=metrics,
|
||||
version=_version.version,
|
||||
prosody_version=version,
|
||||
form=form,
|
||||
show_metrics=show_metrics,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import itertools
|
||||
import math
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
@@ -10,6 +11,7 @@ from quart import (
|
||||
)
|
||||
|
||||
import flask_babel
|
||||
import flask_wtf
|
||||
from flask_babel import _
|
||||
|
||||
from . import prosodyclient
|
||||
@@ -21,11 +23,20 @@ client.default_login_redirect = "main.login"
|
||||
babel = flask_babel.Babel()
|
||||
|
||||
|
||||
BYTE_UNIT_SCALE_MAP = [
|
||||
"B",
|
||||
"kiB",
|
||||
"MiB",
|
||||
"GiB",
|
||||
"TiB",
|
||||
]
|
||||
|
||||
|
||||
@babel.localeselector # type:ignore
|
||||
def selected_locale() -> str:
|
||||
selected = request.accept_languages.best_match(
|
||||
current_app.config['LANGUAGES']
|
||||
)
|
||||
) or current_app.config['LANGUAGES'][0]
|
||||
return selected
|
||||
|
||||
|
||||
@@ -41,12 +52,27 @@ def circle_name(c: typing.Any) -> str:
|
||||
return c.name
|
||||
|
||||
|
||||
def format_bytes(n: float) -> str:
|
||||
scale = math.floor(math.log(n, 1024))
|
||||
try:
|
||||
unit = BYTE_UNIT_SCALE_MAP[scale]
|
||||
factor = 1024**scale
|
||||
except ValueError:
|
||||
unit = "TiB"
|
||||
factor = 1024**4
|
||||
if factor > 1:
|
||||
return "{:.1f} {}".format(n / factor, unit)
|
||||
return "{} {}".format(n, unit)
|
||||
|
||||
|
||||
def init_templating(app: quart.Quart) -> None:
|
||||
app.template_filter("repr")(repr)
|
||||
app.template_filter("format_datetime")(flask_babel.format_datetime)
|
||||
app.template_filter("format_date")(flask_babel.format_date)
|
||||
app.template_filter("format_time")(flask_babel.format_time)
|
||||
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
|
||||
app.template_filter("format_percent")(flask_babel.format_percent)
|
||||
app.template_filter("format_bytes")(format_bytes)
|
||||
app.template_filter("flatten")(flatten)
|
||||
app.template_filter("circle_name")(circle_name)
|
||||
|
||||
@@ -55,3 +81,14 @@ def generate_error_id() -> str:
|
||||
return base64.b32encode(secrets.token_bytes(8)).decode(
|
||||
"ascii"
|
||||
).rstrip("=")
|
||||
|
||||
|
||||
class BaseForm(flask_wtf.FlaskForm): # type:ignore
|
||||
def __init__(self, *args: typing.Any, **kwargs: typing.Any):
|
||||
meta = kwargs["meta"] = dict(kwargs.get("meta", {}))
|
||||
if "locales" not in meta:
|
||||
locale = flask_babel.get_locale()
|
||||
if locale:
|
||||
meta["locales"] = [str(locale)]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -7,6 +7,7 @@ import aiohttp
|
||||
import quart.flask_patch
|
||||
from quart import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
@@ -15,10 +16,9 @@ from quart import (
|
||||
|
||||
import wtforms
|
||||
|
||||
import flask_wtf
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
from .infra import client, selected_locale
|
||||
from .infra import client, selected_locale, BaseForm
|
||||
|
||||
|
||||
bp = Blueprint("invite", __name__)
|
||||
@@ -47,15 +47,30 @@ def context() -> typing.Mapping[str, typing.Any]:
|
||||
|
||||
|
||||
@bp.route("/<id_>")
|
||||
async def view(id_: str) -> str:
|
||||
async def view_old(id_: str) -> quart.Response:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
|
||||
|
||||
@bp.route("/<id_>/")
|
||||
async def view(id_: str) -> typing.Union[quart.Response,
|
||||
typing.Tuple[str, int],
|
||||
str]:
|
||||
try:
|
||||
invite = await client.get_public_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
# invite expired
|
||||
return await render_template("invite_invalid.html")
|
||||
return await render_template("invite_invalid.html"), 404
|
||||
raise
|
||||
|
||||
if invite.reset_localpart is not None:
|
||||
return await render_template(
|
||||
"invite_reset_view.html",
|
||||
invite=invite,
|
||||
invite_id=id_,
|
||||
account_jid="{}@{}".format(invite.reset_localpart, invite.domain)
|
||||
)
|
||||
|
||||
play_store_url = (
|
||||
"https://play.google.com/store/apps/details?" +
|
||||
urllib.parse.urlencode(
|
||||
@@ -68,20 +83,25 @@ async def view(id_: str) -> str:
|
||||
),
|
||||
)
|
||||
)
|
||||
apple_store_url = (
|
||||
"https://apps.apple.com/us/app/tigase-messenger/id1153516838"
|
||||
)
|
||||
apple_store_url = current_app.config["APPLE_STORE_URL"]
|
||||
|
||||
return await render_template(
|
||||
body = await render_template(
|
||||
"invite_view.html",
|
||||
invite=invite,
|
||||
play_store_url=play_store_url,
|
||||
apple_store_url=apple_store_url,
|
||||
f_droid_url="market://details?id=org.snikket.android",
|
||||
invite_id=id_,
|
||||
)
|
||||
return quart.Response(
|
||||
body,
|
||||
headers={
|
||||
"Link": "<{}> rel=\"alternate\"".format(invite.xmpp_uri),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RegisterForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class RegisterForm(BaseForm):
|
||||
localpart = wtforms.StringField(
|
||||
_l("Username"),
|
||||
)
|
||||
@@ -95,7 +115,7 @@ class RegisterForm(flask_wtf.FlaskForm): # type:ignore
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"password",
|
||||
_l("The passwords must match")
|
||||
_l("The passwords must match.")
|
||||
)]
|
||||
)
|
||||
|
||||
@@ -106,7 +126,14 @@ class RegisterForm(flask_wtf.FlaskForm): # type:ignore
|
||||
|
||||
@bp.route("/<id_>/register", methods=["GET", "POST"])
|
||||
async def register(id_: str) -> typing.Union[str, quart.Response]:
|
||||
invite = await client.get_public_invite_by_id(id_)
|
||||
try:
|
||||
invite = await client.get_public_invite_by_id(id_)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
|
||||
if invite.reset_localpart is not None:
|
||||
return redirect(url_for(".reset", id_=id_))
|
||||
form = RegisterForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
@@ -120,15 +147,15 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 409:
|
||||
form.localpart.errors.append(
|
||||
_l("That user name is already taken")
|
||||
_l("That username is already taken.")
|
||||
)
|
||||
elif exc.status == 403:
|
||||
form.localpart.errors.append(
|
||||
_l("Registration was declined for unknown reasons")
|
||||
_l("Registration was declined for unknown reasons.")
|
||||
)
|
||||
elif exc.status == 400:
|
||||
form.localpart.errors.append(
|
||||
_l("The user name was not valid")
|
||||
_l("The username is not valid.")
|
||||
)
|
||||
elif exc.status == 404:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
@@ -145,6 +172,66 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
|
||||
)
|
||||
|
||||
|
||||
class ResetForm(BaseForm):
|
||||
password = wtforms.PasswordField(
|
||||
_l("Password"),
|
||||
)
|
||||
|
||||
password_confirm = wtforms.PasswordField(
|
||||
_l("Confirm password"),
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"password",
|
||||
_l("The passwords must match.")
|
||||
)]
|
||||
)
|
||||
|
||||
action_reset = wtforms.SubmitField(
|
||||
_l("Change password")
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<id_>/reset", methods=["GET", "POST"])
|
||||
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:
|
||||
if exc.status == 404:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
|
||||
if invite.reset_localpart is None:
|
||||
return redirect(url_for(".register", id_=id_))
|
||||
|
||||
form = ResetForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# log the user in? show a guide? no idea.
|
||||
try:
|
||||
jid = await client.register_with_token(
|
||||
username=invite.reset_localpart,
|
||||
password=form.password.data,
|
||||
token=id_,
|
||||
)
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
if exc.status == 403:
|
||||
form.localpart.errors.append(
|
||||
_l("Registration was declined for unknown reasons.")
|
||||
)
|
||||
elif exc.status == 404:
|
||||
return redirect(url_for(".view", id_=id_))
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
http_session[INVITE_SESSION_JID] = jid
|
||||
return redirect(url_for(".reset_success"))
|
||||
|
||||
return await render_template(
|
||||
"invite_reset.html",
|
||||
invite=invite,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/success", methods=["GET", "POST"])
|
||||
async def success() -> str:
|
||||
return await render_template(
|
||||
@@ -153,6 +240,14 @@ async def success() -> str:
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/success/reset", methods=["GET", "POST"])
|
||||
async def reset_success() -> str:
|
||||
return await render_template(
|
||||
"invite_reset_success.html",
|
||||
jid=http_session.get(INVITE_SESSION_JID, ""),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/-")
|
||||
async def index() -> quart.Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@@ -15,23 +15,23 @@ from quart import (
|
||||
render_template,
|
||||
request,
|
||||
Response,
|
||||
flash,
|
||||
)
|
||||
|
||||
import babel
|
||||
import wtforms
|
||||
|
||||
import flask_wtf
|
||||
|
||||
from flask_babel import lazy_gettext as _l, _
|
||||
|
||||
from . import xmpputil, _version
|
||||
from .infra import client
|
||||
from .infra import client, BaseForm
|
||||
|
||||
|
||||
bp = quart.Blueprint("main", __name__)
|
||||
|
||||
|
||||
class LoginForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class LoginForm(BaseForm):
|
||||
address = wtforms.TextField(
|
||||
_l("Address"),
|
||||
validators=[wtforms.validators.InputRequired()],
|
||||
@@ -52,6 +52,9 @@ async def index() -> quart.Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
ERR_CREDENTIALS_INVALID = _l("Invalid username or password.")
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
async def login() -> typing.Union[str, quart.Response]:
|
||||
if client.has_session and (await client.test_session()):
|
||||
@@ -63,33 +66,48 @@ async def login() -> typing.Union[str, quart.Response]:
|
||||
localpart, domain, resource = xmpputil.split_jid(jid)
|
||||
if not localpart:
|
||||
localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"]
|
||||
jid = "{}@{}".format(localpart, domain)
|
||||
password = form.password.data
|
||||
try:
|
||||
await client.login(jid, password)
|
||||
except quart.exceptions.Unauthorized:
|
||||
form.password.errors.append(
|
||||
_("Invalid user name or password.")
|
||||
)
|
||||
if domain != current_app.config["SNIKKET_DOMAIN"]:
|
||||
# (a) prosody throws a 400 at us and I prefer to catch that here
|
||||
# and (b) I don’t want to pass on this obviously not-for-here
|
||||
# password further than necessary.
|
||||
form.password.errors.append(ERR_CREDENTIALS_INVALID)
|
||||
else:
|
||||
return redirect(url_for('user.index'))
|
||||
jid = "{}@{}".format(localpart, domain)
|
||||
password = form.password.data
|
||||
try:
|
||||
await client.login(jid, password)
|
||||
except quart.exceptions.Unauthorized:
|
||||
form.password.errors.append(ERR_CREDENTIALS_INVALID)
|
||||
else:
|
||||
await flash(
|
||||
_("Login successful!"),
|
||||
"success"
|
||||
)
|
||||
return redirect(url_for('user.index'))
|
||||
|
||||
return await render_template("login.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/meta/about.html")
|
||||
async def about() -> str:
|
||||
version = None
|
||||
extra_versions = {}
|
||||
if current_app.debug:
|
||||
|
||||
if current_app.debug or client.is_admin_session:
|
||||
version = _version.version
|
||||
extra_versions["Quart"] = quart.__version__
|
||||
extra_versions["aiohttp"] = aiohttp.__version__
|
||||
extra_versions["babel"] = babel.__version__
|
||||
extra_versions["wtforms"] = wtforms.__version__
|
||||
extra_versions["flask-wtf"] = flask_wtf.__version__
|
||||
try:
|
||||
extra_versions["Prosody"] = await client.get_server_version()
|
||||
except quart.exceptions.Unauthorized:
|
||||
extra_versions["Prosody"] = "unknown"
|
||||
|
||||
return await render_template(
|
||||
"about.html",
|
||||
version=_version.version,
|
||||
version=version,
|
||||
extra_versions=extra_versions,
|
||||
)
|
||||
|
||||
@@ -105,6 +123,7 @@ def repad(s: str) -> str:
|
||||
|
||||
@bp.route("/avatar/<from_>/<code>")
|
||||
async def avatar(from_: str, code: str) -> quart.Response:
|
||||
etag: typing.Optional[str]
|
||||
try:
|
||||
etag = request.headers["if-none-match"]
|
||||
except KeyError:
|
||||
@@ -144,3 +163,8 @@ async def avatar(from_: str, code: str) -> quart.Response:
|
||||
|
||||
response.set_data(data)
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/_health")
|
||||
async def health() -> Response:
|
||||
return Response("STATUS OK", content_type="text/plain")
|
||||
|
||||
@@ -44,6 +44,15 @@ class AdminUserInfo:
|
||||
display_name: typing.Optional[str]
|
||||
email: typing.Optional[str]
|
||||
phone: typing.Optional[str]
|
||||
roles: typing.Optional[typing.List[str]]
|
||||
|
||||
@property
|
||||
def has_admin_role(self) -> bool:
|
||||
return bool(self.roles and "prosody:admin" in self.roles)
|
||||
|
||||
@property
|
||||
def has_restricted_role(self) -> bool:
|
||||
return bool(self.roles and "prosody:restricted" in self.roles)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
@@ -55,11 +64,13 @@ class AdminUserInfo:
|
||||
display_name=data.get("display_name") or None,
|
||||
email=data.get("email") or None,
|
||||
phone=data.get("phone") or None,
|
||||
roles=data.get("roles"),
|
||||
)
|
||||
|
||||
|
||||
class InviteType(enum.Enum):
|
||||
REGISTER = "register"
|
||||
ROSTER = "roster"
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@@ -100,6 +111,7 @@ class AdminInviteInfo:
|
||||
class AdminGroupInfo:
|
||||
id_: str
|
||||
name: str
|
||||
muc_jid: typing.Optional[str]
|
||||
members: typing.Collection[str]
|
||||
|
||||
@classmethod
|
||||
@@ -110,6 +122,7 @@ class AdminGroupInfo:
|
||||
return cls(
|
||||
id_=data["id"],
|
||||
name=data["name"],
|
||||
muc_jid=data.get("muc_jid") or None,
|
||||
members=data.get("members", []),
|
||||
)
|
||||
|
||||
@@ -118,6 +131,8 @@ class AdminGroupInfo:
|
||||
class PublicInviteInfo:
|
||||
inviter: typing.Optional[str]
|
||||
xmpp_uri: str
|
||||
reset_localpart: typing.Optional[str]
|
||||
domain: str
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
@@ -127,6 +142,8 @@ class PublicInviteInfo:
|
||||
return cls(
|
||||
inviter=data.get("inviter") or None,
|
||||
xmpp_uri=data["uri"],
|
||||
reset_localpart=data.get("reset", None),
|
||||
domain=data["domain"],
|
||||
)
|
||||
|
||||
|
||||
@@ -137,6 +154,7 @@ class HTTPSessionManager:
|
||||
async def _create(self) -> aiohttp.ClientSession:
|
||||
return aiohttp.ClientSession(headers={
|
||||
"Accept": "application/json",
|
||||
"Host": current_app.config["SNIKKET_DOMAIN"],
|
||||
})
|
||||
|
||||
async def teardown(self, exc: typing.Optional[BaseException]) -> None:
|
||||
@@ -197,6 +215,7 @@ class HTTPAuthSessionManager(HTTPSessionManager):
|
||||
headers={
|
||||
"Authorization": "Bearer {}".format(token),
|
||||
"Accept": "application/json",
|
||||
"Host": current_app.config["SNIKKET_DOMAIN"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -323,15 +342,18 @@ class ProsodyClient:
|
||||
)
|
||||
)
|
||||
|
||||
def _store_token_in_session(self, token_info: TokenInfo) -> None:
|
||||
http_session[self.SESSION_TOKEN] = token_info.token
|
||||
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
|
||||
|
||||
async def login(self, jid: str, password: str) -> bool:
|
||||
async with self._plain_session as session:
|
||||
token_info = await self._oauth2_bearer_token(
|
||||
session, jid, password,
|
||||
)
|
||||
|
||||
http_session[self.SESSION_TOKEN] = token_info.token
|
||||
self._store_token_in_session(token_info)
|
||||
http_session[self.SESSION_ADDRESS] = jid
|
||||
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
|
||||
return True
|
||||
|
||||
@property
|
||||
@@ -436,6 +458,13 @@ class ProsodyClient:
|
||||
headers=final_headers,
|
||||
data=serialised) as resp:
|
||||
if resp.status != 200:
|
||||
self.logger.debug(
|
||||
"IQ HTTP response (in-reply-to id=%s) with non-OK status "
|
||||
"%s: %s",
|
||||
id_,
|
||||
resp.status,
|
||||
resp.reason,
|
||||
)
|
||||
abort(resp.status)
|
||||
reply_payload = await resp.read()
|
||||
self.logger.debug(
|
||||
@@ -481,9 +510,32 @@ class ProsodyClient:
|
||||
"to": self.session_address,
|
||||
}
|
||||
|
||||
async with session.post(self._rest_endpoint, data=req) as resp:
|
||||
async with session.post(self._rest_endpoint, json=req) as resp:
|
||||
return resp.status == 200
|
||||
|
||||
@autosession
|
||||
async def get_server_version(self, session: aiohttp.ClientSession) -> str:
|
||||
_, domain, _ = split_jid(self.session_address)
|
||||
req = {
|
||||
"kind": "iq",
|
||||
"type": "get",
|
||||
"version": {},
|
||||
"to": domain,
|
||||
}
|
||||
|
||||
async with session.post(self._rest_endpoint, json=req) as resp:
|
||||
if resp.status != 200:
|
||||
return "unknwn"
|
||||
try:
|
||||
return (await resp.json())["version"]["version"]
|
||||
except Exception as exc:
|
||||
self.logger.debug(
|
||||
"failed to parse prosody version from response"
|
||||
" (%s: %s)",
|
||||
type(exc), exc,
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
@autosession
|
||||
async def get_user_nickname(
|
||||
self,
|
||||
@@ -758,7 +810,7 @@ class ProsodyClient:
|
||||
# got there, replacing the current session token on the way.
|
||||
|
||||
async with self._plain_session as session:
|
||||
token = await self._oauth2_bearer_token(
|
||||
token_info = await self._oauth2_bearer_token(
|
||||
session,
|
||||
self.session_address,
|
||||
current_password,
|
||||
@@ -770,14 +822,14 @@ class ProsodyClient:
|
||||
new_password
|
||||
),
|
||||
headers={
|
||||
"Authorization": "Bearer {}".format(token),
|
||||
"Authorization": "Bearer {}".format(token_info.token),
|
||||
},
|
||||
sensitive=True,
|
||||
)
|
||||
# TODO: error handling
|
||||
# TODO: obtain a new token using the new password to allow the
|
||||
# server to expire/revoke all tokens on password change.
|
||||
http_session[self.SESSION_TOKEN] = token
|
||||
self._store_token_in_session(token_info)
|
||||
|
||||
def _raise_error_from_response(
|
||||
self,
|
||||
@@ -816,6 +868,29 @@ class ProsodyClient:
|
||||
self._raise_error_from_response(resp)
|
||||
return AdminUserInfo.from_api_response(await resp.json())
|
||||
|
||||
@autosession
|
||||
async def update_user(
|
||||
self,
|
||||
localpart: str,
|
||||
*,
|
||||
display_name: typing.Optional[str],
|
||||
roles: typing.Optional[typing.Collection[str]],
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
payload: typing.Dict[str, typing.Any] = {
|
||||
"username": localpart,
|
||||
}
|
||||
if display_name is not None:
|
||||
payload["display_name"] = display_name
|
||||
if roles is not None:
|
||||
payload["roles"] = list(roles)
|
||||
|
||||
async with session.put(
|
||||
self._admin_v1_endpoint("/users/{}".format(localpart)),
|
||||
json=payload,
|
||||
) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
|
||||
@autosession
|
||||
async def get_user_debug_info(
|
||||
self,
|
||||
@@ -944,10 +1019,12 @@ class ProsodyClient:
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
create_muc: bool = True,
|
||||
session: aiohttp.ClientSession,
|
||||
) -> AdminGroupInfo:
|
||||
payload = {
|
||||
"name": name,
|
||||
"create_muc": create_muc,
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
@@ -1098,3 +1175,41 @@ class ProsodyClient:
|
||||
json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
return (await resp.json())["jid"]
|
||||
|
||||
@autosession
|
||||
async def get_system_metrics(
|
||||
self,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> typing.Mapping:
|
||||
async with session.get(
|
||||
self._admin_v1_endpoint("/server/metrics"),
|
||||
) as resp:
|
||||
if resp.status == 404:
|
||||
return {}
|
||||
self._raise_error_from_response(resp)
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
@autosession
|
||||
async def post_announcement(
|
||||
self,
|
||||
body: str,
|
||||
recipients: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession) -> None:
|
||||
recipients_payload: typing.Union[str, typing.Sequence[str]]
|
||||
if recipients == "self":
|
||||
recipients_payload = [self.session_address]
|
||||
else:
|
||||
recipients_payload = recipients
|
||||
|
||||
payload = {
|
||||
"recipients": recipients_payload,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
self._admin_v1_endpoint("/server/announcement"),
|
||||
json=payload) as resp:
|
||||
self._raise_error_from_response(resp)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -11,15 +11,15 @@ $colours: (
|
||||
#f6f5f4
|
||||
],
|
||||
"blue": [
|
||||
#062943,
|
||||
#0f3d62,
|
||||
#0e4c76,
|
||||
#226494,
|
||||
#418fc7,
|
||||
#72b2e3,
|
||||
#9dccf0,
|
||||
#b5d8f3,
|
||||
#e4f3fd
|
||||
#062243,
|
||||
#0f3462,
|
||||
#0e4276,
|
||||
#225994,
|
||||
#4182c7,
|
||||
#72a7e3,
|
||||
#9dc4f0,
|
||||
#b5d2f3,
|
||||
#e4f0fd
|
||||
],
|
||||
"red": [
|
||||
#340e03,
|
||||
@@ -39,20 +39,20 @@ $colours: (
|
||||
#a07501,
|
||||
#c79b0e,
|
||||
#f4ce3f,
|
||||
#feed93,
|
||||
#fef6c1,
|
||||
#fffbe8
|
||||
#fee577,
|
||||
#fef1c1,
|
||||
#fff8e8
|
||||
],
|
||||
"green": [
|
||||
#052f03,
|
||||
#0c4608,
|
||||
#197713,
|
||||
#218a1b,
|
||||
#55c644,
|
||||
#81e06e,
|
||||
#abed9c,
|
||||
#cef6c5,
|
||||
#ecfbe6
|
||||
#172f03,
|
||||
#244608,
|
||||
#407713,
|
||||
#548f19,
|
||||
#7fc644,
|
||||
#a1e06e,
|
||||
#c0ed9c,
|
||||
#dbf6c5,
|
||||
#effbe6
|
||||
]
|
||||
);
|
||||
|
||||
@@ -171,6 +171,7 @@ $w-l4: 4rem;
|
||||
$w-l5: 6rem;
|
||||
$w-l6: 8rem;
|
||||
$w-l7: 12rem;
|
||||
$w-l8: 16rem;
|
||||
|
||||
$font-sans: "Noto Sans", sans-serif;
|
||||
$font-serif: serif;
|
||||
@@ -251,3 +252,4 @@ $h-sizes: [200.0%, 174.11011266%, 151.57165665%, 131.95079108%, 114.8698355%, 10
|
||||
$h-small-sizes: [150.0%, 138.31618672%, 127.54245006%, 117.60790225%, 108.44717712%, 100.0%];
|
||||
$small-screen-threshold: 40rem;
|
||||
$medium-screen-threshold: 60rem;
|
||||
$large-screen-threshold: 80rem;
|
||||
|
||||
@@ -33,19 +33,41 @@ body {
|
||||
|
||||
main {
|
||||
padding: $w-l1;
|
||||
margin-left: auto;
|
||||
max-width: 60rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#mwrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
> .filler, > .flashbox {
|
||||
flex: 1 1 1rem;
|
||||
}
|
||||
|
||||
> main {
|
||||
flex: 0 1 60rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $large-screen-threshold) {
|
||||
#mwrap {
|
||||
display: block;
|
||||
|
||||
> main {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flashbox > div.box > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* top bar */
|
||||
|
||||
@mixin snikket-logo {
|
||||
background-image: url('/static/img/snikket-logo.svg');
|
||||
background-image: url('../img/snikket-logo.svg');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: $w-s2 0em;
|
||||
@@ -67,6 +89,10 @@ div#topbar {
|
||||
font-size: $_top-h-size;
|
||||
line-height: 1.5;
|
||||
|
||||
body.debug & {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-screen-threshold) {
|
||||
font-size: $_top-h-small-size;
|
||||
}
|
||||
@@ -134,22 +160,20 @@ body > footer {
|
||||
background-color: $gray-100;
|
||||
color: $gray-800;
|
||||
padding: 0 $w-l1;
|
||||
font-size: 92.21079115%;
|
||||
|
||||
ul {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
text-align: center;
|
||||
line-height: 1.6267076567643135;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: $w-l1 0;
|
||||
}
|
||||
|
||||
li:before {
|
||||
content: '•';
|
||||
padding-right: $w-s2;
|
||||
display: block;
|
||||
margin: $w-s1 0;
|
||||
}
|
||||
|
||||
a, a:visited, a:hover, a:active, a:focus {
|
||||
@@ -171,10 +195,10 @@ body > footer {
|
||||
|
||||
@for $n from 1 through 6 {
|
||||
div.form h#{$n}.form-title {
|
||||
font-size: 100%;
|
||||
font-weight: bold;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5em;
|
||||
font-size: nth($h-sizes, 4);
|
||||
/* font-weight: bold; */
|
||||
line-height: 1.5 / (nth($h-sizes, 4) / 100%);
|
||||
margin: 1.5em / (nth($h-sizes, 4) / 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +246,27 @@ div.form {
|
||||
}
|
||||
|
||||
div.form.layout-expanded {
|
||||
label {
|
||||
label, legend {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: block;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="radio"] + label, input[type="checkbox"] + label {
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
div.f-ebox {
|
||||
margin-bottom: $w-l1;
|
||||
margin: 1.5em 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
div.f-bbox {
|
||||
@@ -242,7 +280,6 @@ div.form.layout-expanded {
|
||||
border: none;
|
||||
border-bottom: $w-s4 solid $primary-500;
|
||||
margin-bottom: -$w-s4;
|
||||
padding: 0 $w-s3;
|
||||
}
|
||||
|
||||
input[type=$type].has-error {
|
||||
@@ -317,6 +354,15 @@ div.form.layout-expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radio-button-ext > label > p {
|
||||
margin-left: 1.75rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.radio-button-ext > label .icon {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
div.select-wrap {
|
||||
display: block;
|
||||
border-bottom: $w-s4 solid $primary-500;
|
||||
@@ -374,10 +420,18 @@ div.form.layout-expanded {
|
||||
}
|
||||
}
|
||||
|
||||
div.avatar-wrap {
|
||||
> .avatar {
|
||||
margin: 0;
|
||||
margin-right: $w-0;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: $w-s4 solid $primary-500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:hover {
|
||||
@@ -389,11 +443,12 @@ div.form.layout-expanded {
|
||||
}
|
||||
}
|
||||
|
||||
.f-ebox > ul {
|
||||
fieldset > ul {
|
||||
/* radio group */
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: $w-s1 0;
|
||||
padding-left: $w-l1;
|
||||
|
||||
> li {
|
||||
margin: 0;
|
||||
@@ -455,7 +510,7 @@ input[type="submit"], button, .button {
|
||||
&.primary {
|
||||
background: linear-gradient(0deg, $primary-500, $primary-600);
|
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
color: $primary-900;
|
||||
color: white;
|
||||
border: none;
|
||||
/* TODO: fix vertical rhyhtm ... */
|
||||
border-radius: $w-s4;
|
||||
@@ -758,40 +813,10 @@ body#login {
|
||||
}
|
||||
|
||||
|
||||
/* welcome screen specials */
|
||||
|
||||
div.welcome-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > .card {
|
||||
flex: 1 0 $w-l7;
|
||||
margin: $w-s1;
|
||||
@extend .el-2;
|
||||
padding: $w-s1 $w-l1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
& > a.card {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
& > h2 {
|
||||
color: $primary-200;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
& > .card:hover, & > .card:active, & > .card:focus, & > .card:focus-within {
|
||||
@extend .el-3;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* admin area specials */
|
||||
|
||||
#topbar > div.admin-note {
|
||||
color: $alert-500;
|
||||
color: $alert-400;
|
||||
font-size: nth($h-sizes, 5);
|
||||
margin-left: $w-l1;
|
||||
}
|
||||
@@ -799,10 +824,14 @@ div.welcome-cards {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: $w-s1;
|
||||
}
|
||||
td, th {
|
||||
padding: $w-s1;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
div.elevated {
|
||||
@@ -860,6 +889,104 @@ ul.inline {
|
||||
}
|
||||
|
||||
|
||||
/* welcome screen specials */
|
||||
|
||||
#home main {
|
||||
> h1, > p {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
nav.welcome {
|
||||
> ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
|
||||
> li {
|
||||
@extend .el-3;
|
||||
background-color: white;
|
||||
|
||||
flex: 1 0 $w-l7;
|
||||
margin: $w-s1;
|
||||
padding: $w-s1 $w-l1;
|
||||
text-align: center;
|
||||
max-width: $w-l8;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&.wide {
|
||||
flex: 1 0 auto;
|
||||
/* display: block; */
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
margin: $w-l1 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: $w-l1 $w-0;
|
||||
--margin: $w-0 * 2;
|
||||
width: calc(100% - var(--margin));
|
||||
}
|
||||
|
||||
p {
|
||||
margin-left: $w-0;
|
||||
margin-right: $w-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.profile-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: $w-l1 0;
|
||||
text-align: left;
|
||||
|
||||
> div.picture {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
> div.details {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .display-name {
|
||||
font-size: nth($h-small-sizes, 5);
|
||||
line-height: 1.5 / (nth($h-small-sizes, 5) / 100%);
|
||||
}
|
||||
|
||||
> .address {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
> input {
|
||||
flex: 1 1 auto;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
> .button {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* linearisation / responsive stuff */
|
||||
|
||||
@media screen and (max-width: $medium-screen-threshold) {
|
||||
@@ -899,6 +1026,23 @@ ul.inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input[type="submit"], button, .button {
|
||||
&.slimmify {
|
||||
> svg.icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
> span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
top: -100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* clipboard button */
|
||||
@@ -952,6 +1096,12 @@ pre.guru-meditation {
|
||||
}
|
||||
}
|
||||
|
||||
body#login {
|
||||
.form-title {
|
||||
color: $primary-800;
|
||||
}
|
||||
}
|
||||
|
||||
body > footer {
|
||||
background-color: $gray-200;
|
||||
color: $gray-800;
|
||||
@@ -999,14 +1149,10 @@ pre.guru-meditation {
|
||||
}
|
||||
}
|
||||
|
||||
div.welcome-cards {
|
||||
& > .card {
|
||||
background: black;
|
||||
}
|
||||
|
||||
& > a.card {
|
||||
& > h2 {
|
||||
color: $primary-800;
|
||||
nav.welcome {
|
||||
> ul {
|
||||
> li {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1053,11 +1199,11 @@ pre.guru-meditation {
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: linear-gradient(0deg, $gray-400, $gray-500);
|
||||
background: linear-gradient(0deg, $gray-200, $gray-300);
|
||||
color: $gray-900;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: linear-gradient(0deg, $gray-500, $gray-600);
|
||||
background: linear-gradient(0deg, $gray-300, $gray-400);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -1120,3 +1266,52 @@ pre.guru-meditation {
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* tooltip magic */
|
||||
|
||||
.with-tooltip {
|
||||
position: relative;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.with-tooltip:before {
|
||||
content: attr(data-tooltip); /* here's the magic */
|
||||
position: absolute;
|
||||
font-size: 87.05505633%;
|
||||
|
||||
/* vertically center */
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
left: 50%;
|
||||
margin-bottom: $w-s2;
|
||||
|
||||
/* basic styles */
|
||||
width:$w-l7;
|
||||
padding: $w-s1;
|
||||
background: black;
|
||||
color: $gray-900;
|
||||
text-align: center;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
.with-tooltip:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
left: 50%;
|
||||
margin-bottom: -$w-s1;
|
||||
|
||||
/* the arrow */
|
||||
border: 10px solid black;
|
||||
border-color: black transparent transparent transparent;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
.with-tooltip:hover:before, .with-tooltip:hover:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ div.install-buttons {
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
list-style-type: none;
|
||||
margin: $w-l1 0;
|
||||
padding: 0;
|
||||
@@ -74,6 +76,10 @@ img.play {
|
||||
height: $w-l3;
|
||||
}
|
||||
|
||||
img.fdroid {
|
||||
height: $w-l3;
|
||||
}
|
||||
|
||||
.tabbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -183,3 +189,13 @@ div.form.layout-expanded .lwrap {
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
/* dark mode */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
div.form.layout-expanded .lwrap {
|
||||
span {
|
||||
background: $gray-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
snikket_web/static/img/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
snikket_web/static/img/android-chrome-256x256.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
snikket_web/static/img/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
snikket_web/static/img/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
snikket_web/static/img/f-droid-badge.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
snikket_web/static/img/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
snikket_web/static/img/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
@@ -17,6 +17,11 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M9 16.2l-3.5-3.5c-.39-.39-1.01-.39-1.4 0-.39.39-.39 1.01 0 1.4l4.19 4.19c.39.39 1.02.39 1.41 0L20.3 7.7c.39-.39.39-1.01 0-1.4-.39-.39-1.01-.39-1.4 0L9 16.2z" />
|
||||
</symbol>
|
||||
<!-- from: action/delete/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-delete" 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 2v10zM18 4h-2.5l-.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-1z" />
|
||||
</symbol>
|
||||
<!-- from: action/logout/materialicons/24px.svg -->
|
||||
<symbol id="icon-logout" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
@@ -32,6 +37,11 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M10.79 16.29c.39.39 1.02.39 1.41 0l3.59-3.59c.39-.39.39-1.02 0-1.41L12.2 7.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L12.67 11H4c-.55 0-1 .45-1 1s.45 1 1 1h8.67l-1.88 1.88c-.39.39-.38 1.03 0 1.41zM19 3H5c-1.11 0-2 .9-2 2v3c0 .55.45 1 1 1s1-.45 1-1V6c0-.55.45-1 1-1h12c.55 0 1 .45 1 1v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1v3c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
</symbol>
|
||||
<!-- from: action/lock/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-lock" viewBox="0 0 24 24">
|
||||
<g fill="none"><path d="M0 0h24v24H0V0z" /><path d="M0 0h24v24H0V0z" opacity=".87" /></g>
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z" />
|
||||
</symbol>
|
||||
<!-- from: communication/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>
|
||||
@@ -42,6 +52,12 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
||||
</symbol>
|
||||
<!-- from: communication/rss_feed/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-broadcast" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<circle cx="6.18" cy="17.82" r="2.18" />
|
||||
<path d="M5.59 10.23c-.84-.14-1.59.55-1.59 1.4 0 .71.53 1.28 1.23 1.4 2.92.51 5.22 2.82 5.74 5.74.12.7.69 1.23 1.4 1.23.85 0 1.54-.75 1.41-1.59-.68-4.2-3.99-7.51-8.19-8.18zm-.03-5.71C4.73 4.43 4 5.1 4 5.93c0 .73.55 1.33 1.27 1.4 6.01.6 10.79 5.38 11.39 11.39.07.73.67 1.28 1.4 1.28.84 0 1.5-.73 1.42-1.56-.73-7.34-6.57-13.19-13.92-13.92z" />
|
||||
</symbol>
|
||||
<!-- from: content/add_circle_outline/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-add" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -52,11 +68,6 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0" fill="none" />
|
||||
<path d="M8 11h8v2H8zm12.1 1H22c0-2.76-2.24-5-5-5h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1zM3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM19 12h-2v3h-3v2h3v3h2v-3h3v-2h-3z" />
|
||||
</symbol>
|
||||
<!-- from: content/create/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-edit" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</symbol>
|
||||
<!-- from: content/remove_circle_outline/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-remove" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -72,6 +83,11 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="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: navigation/arrow_back/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-back" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
@@ -97,14 +113,49 @@ licensed under the terms of the Apache 2.0 License -->
|
||||
<rect fill="none" height="24" width="24" />
|
||||
<g><path d="M12,12.75c1.63,0,3.07,0.39,4.24,0.9c1.08,0.48,1.76,1.56,1.76,2.73L18,17c0,0.55-0.45,1-1,1H7c-0.55,0-1-0.45-1-1l0-0.61 c0-1.18,0.68-2.26,1.76-2.73C8.93,13.14,10.37,12.75,12,12.75z M4,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2 C2,12.1,2.9,13,4,13z M5.13,14.1C4.76,14.04,4.39,14,4,14c-0.99,0-1.93,0.21-2.78,0.58C0.48,14.9,0,15.62,0,16.43L0,17 c0,0.55,0.45,1,1,1l3.5,0v-1.61C4.5,15.56,4.73,14.78,5.13,14.1z M20,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2 C18,12.1,18.9,13,20,13z M24,16.43c0-0.81-0.48-1.53-1.22-1.85C21.93,14.21,20.99,14,20,14c-0.39,0-0.76,0.04-1.13,0.1 c0.4,0.68,0.63,1.46,0.63,2.29V18l3.5,0c0.55,0,1-0.45,1-1L24,16.43z M12,6c1.66,0,3,1.34,3,3c0,1.66-1.34,3-3,3s-3-1.34-3-3 C9,7.34,10.34,6,12,6z" /></g>
|
||||
</symbol>
|
||||
<!-- from: social/people/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-people" viewBox="0 0 24 24">
|
||||
<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/group_add/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-create_group" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M7 10H5V8c0-.55-.45-1-1-1s-1 .45-1 1v2H1c-.55 0-1 .45-1 1s.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1v-2h2c.55 0 1-.45 1-1s-.45-1-1-1zm11 1c1.66 0 2.99-1.34 2.99-3S19.66 5 18 5c-.32 0-.63.05-.91.14.57.81.9 1.79.9 2.86s-.34 2.04-.9 2.86c.28.09.59.14.91.14zm-5 0c1.66 0 2.99-1.34 2.99-3S14.66 5 13 5s-3 1.34-3 3 1.34 3 3 3zm0 2c-2 0-6 1-6 3v1c0 .55.45 1 1 1h10c.55 0 1-.45 1-1v-1c0-2-4-3-6-3zm6.62.16c.83.73 1.38 1.66 1.38 2.84v1.5c0 .17-.02.34-.05.5h2.55c.28 0 .5-.22.5-.5V16c0-1.54-2.37-2.49-4.38-2.84z" />
|
||||
</symbol>
|
||||
<!-- from: social/person_add/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-add_user" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V8c0-.55-.45-1-1-1s-1 .45-1 1v2H2c-.55 0-1 .45-1 1s.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1v-2h2c.55 0 1-.45 1-1s-.45-1-1-1H6zm9 4c-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/person_remove/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-remove_user" viewBox="0 0 24 24">
|
||||
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
|
||||
<g><path d="M14,8c0-2.21-1.79-4-4-4S6,5.79,6,8s1.79,4,4,4S14,10.21,14,8z M2,18v1c0,0.55,0.45,1,1,1h14c0.55,0,1-0.45,1-1v-1 c0-2.66-5.33-4-8-4S2,15.34,2,18z M18,10h4c0.55,0,1,0.45,1,1v0c0,0.55-0.45,1-1,1h-4c-0.55,0-1-0.45-1-1v0 C17,10.45,17.45,10,18,10z" /></g>
|
||||
</symbol>
|
||||
<!-- from: navigation/close/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-close" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z" />
|
||||
</symbol>
|
||||
<!-- from: image/edit/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-edit" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</symbol>
|
||||
<!-- from: action/admin_panel_settings/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-admin" 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="M17,11c0.34,0,0.67,0.04,1,0.09V7.58c0-0.8-0.47-1.52-1.2-1.83l-5.5-2.4c-0.51-0.22-1.09-0.22-1.6,0l-5.5,2.4 C3.47,6.07,3,6.79,3,7.58v3.6c0,4.54,3.2,8.79,7.5,9.82c0.55-0.13,1.08-0.32,1.6-0.55C11.41,19.47,11,18.28,11,17 C11,13.69,13.69,11,17,11z" /><path d="M17,13c-2.21,0-4,1.79-4,4c0,2.21,1.79,4,4,4s4-1.79,4-4C21,14.79,19.21,13,17,13z M17,14.38c0.62,0,1.12,0.51,1.12,1.12 s-0.51,1.12-1.12,1.12s-1.12-0.51-1.12-1.12S16.38,14.38,17,14.38z M17,19.75c-0.93,0-1.74-0.46-2.24-1.17 c0.05-0.72,1.51-1.08,2.24-1.08s2.19,0.36,2.24,1.08C18.74,19.29,17.93,19.75,17,19.75z" /></g></g>
|
||||
</symbol>
|
||||
<!-- from: content/link/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-link" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M17 7h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c1.65 0 3 1.35 3 3s-1.35 3-3 3h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-9 5c0 .55.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1H9c-.55 0-1 .45-1 1zm2 3H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h3c.55 0 1-.45 1-1s-.45-1-1-1H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h3c.55 0 1-.45 1-1s-.45-1-1-1z" />
|
||||
</symbol>
|
||||
<!-- from: content/insights/materialiconsround/24px.svg -->
|
||||
<symbol id="icon-insights" viewBox="0 0 24 24">
|
||||
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
|
||||
<g><g><path d="M21,8c-1.45,0-2.26,1.44-1.93,2.51l-3.55,3.56c-0.3-0.09-0.74-0.09-1.04,0l-2.55-2.55C12.27,10.45,11.46,9,10,9 c-1.45,0-2.27,1.44-1.93,2.52l-4.56,4.55C2.44,15.74,1,16.55,1,18c0,1.1,0.9,2,2,2c1.45,0,2.26-1.44,1.93-2.51l4.55-4.56 c0.3,0.09,0.74,0.09,1.04,0l2.55,2.55C12.73,16.55,13.54,18,15,18c1.45,0,2.27-1.44,1.93-2.52l3.56-3.55 C21.56,12.26,23,11.45,23,10C23,8.9,22.1,8,21,8z" /><polygon points="15,9 15.94,6.93 18,6 15.94,5.07 15,3 14.08,5.07 12,6 14.08,6.93" /><polygon points="3.5,11 4,9 6,8.5 4,8 3.5,6 3,8 1,8.5 3,9" /></g></g>
|
||||
</symbol>
|
||||
</defs></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
33
snikket_web/static/img/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="260.000000pt" height="260.000000pt" viewBox="0 0 260.000000 260.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,260.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1169 2496 c-2 -2 -24 -6 -49 -9 -51 -6 -196 -42 -237 -59 -360 -146
|
||||
-607 -401 -723 -748 -49 -143 -62 -230 -62 -386 0 -177 27 -297 109 -498 115
|
||||
-282 109 -516 -18 -660 l-30 -35 38 6 c151 22 316 93 400 172 12 12 25 21 28
|
||||
21 3 0 41 -20 83 -45 181 -104 366 -152 592 -153 133 0 240 16 362 54 84 27
|
||||
228 92 228 103 0 4 -14 15 -31 25 -254 150 -478 469 -534 761 -5 22 -10 47
|
||||
-11 55 -21 97 -21 303 1 409 58 293 200 534 420 716 33 28 81 64 108 80 26 17
|
||||
47 33 47 36 0 3 -39 24 -87 47 -86 40 -217 83 -298 97 -46 7 -329 17 -336 11z
|
||||
m-117 -379 c90 -53 140 -137 140 -237 0 -231 -271 -353 -445 -199 -62 54 -89
|
||||
113 -90 197 -1 125 70 223 193 263 49 16 155 4 202 -24z"/>
|
||||
<path d="M847 2029 c-56 -29 -96 -101 -91 -161 12 -128 153 -196 261 -124 37
|
||||
24 73 83 73 118 0 23 -4 25 -45 24 -75 -2 -108 46 -93 139 2 17 -3 20 -33 21
|
||||
-20 1 -52 -7 -72 -17z"/>
|
||||
<path d="M1885 2218 c-136 -100 -265 -251 -340 -398 -40 -80 -92 -223 -100
|
||||
-281 -4 -24 -8 -46 -10 -49 -2 -3 -6 -35 -9 -71 l-6 -65 33 -1 c125 -3 325
|
||||
-57 461 -124 77 -39 178 -101 204 -126 7 -7 17 -13 20 -13 14 0 178 -166 216
|
||||
-219 39 -53 39 -53 52 -30 18 32 63 173 75 234 71 364 -31 723 -281 997 -64
|
||||
70 -219 198 -238 198 -4 0 -38 -23 -77 -52z"/>
|
||||
<path d="M1418 1245 c-7 -8 17 -171 37 -244 9 -35 28 -93 42 -128 l26 -64 41
|
||||
7 c136 22 368 118 446 182 l31 27 -50 33 c-146 94 -330 161 -491 178 -36 3
|
||||
-68 8 -71 10 -3 2 -8 1 -11 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
1
snikket_web/static/img/snikket-logo-text.svg
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
@@ -3,5 +3,7 @@
|
||||
{#- -#}
|
||||
<li>{% trans about_url=url_for('main.about') %}A <a href="{{ about_url }}">Snikket</a> service{% endtrans %}</li>
|
||||
{#- -#}
|
||||
<li>{% trans %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company.{% endtrans %}</li>
|
||||
{#- -#}
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "library.j2" import standard_button %}
|
||||
{% block head_lead %}
|
||||
<title>About Snikket</title>
|
||||
<title>{% trans %}About Snikket{% endtrans %}</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<main>
|
||||
@@ -14,10 +14,12 @@
|
||||
<p>{% trans agpl_url="https://www.gnu.org/licenses/agpl.html" %}The web portal software is licensed under the terms of the <a href="{{ agpl_url }}">Affero GNU General Public License, version 3.0 or later</a>. The full terms of the license can be reviewed using the aforementioned link.{% endtrans %}</p>
|
||||
<p>{% trans source_url="https://github.com/snikket-im/snikket-web-portal/" %}The source code of the web portal can be downloaded and viewed in <a href="{{ source_url }}">its GitHub repository</a>.{% endtrans %}</p>
|
||||
<p>{% trans source_url="https://material.io/resources/icons/", apache20_url="https://www.apache.org/licenses/LICENSE-2.0.txt" %}The icons used in the web portal are <a href="{{ source_url }}">Google’s Material Icons</a>, made available by Google under the terms of the <a href="{{ apache20_url }}">Apache 2.0 License</a>.{% endtrans %}</p>
|
||||
<h3>{% trans %}Trademarks{% endtrans %}</h3>
|
||||
<p>{% trans trademarks_url="https://snikket.org/about/trademarks/" %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company. For more information about the trademarks, visit the <a href="{{ trademarks_url }}">Snikket Trademarks information page</a>.{% endtrans %}
|
||||
<h3>{% trans %}Software Versions{% endtrans %}</h3>
|
||||
<pre>Snikket Server
|
||||
Domain: {{ config["SNIKKET_DOMAIN"] }}
|
||||
Snikket Web Portal ({{ version }})
|
||||
Snikket Web Portal{% if version %} ({{ version }}){% endif %}
|
||||
{%- if extra_versions -%}
|
||||
{% for name, version in extra_versions.items() %}
|
||||
{{ name }} ({{ version }}){% endfor %}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
{% from "library.j2" import action_button, custom_form_button, form_button, circle_name %}
|
||||
{% 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 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 -}}
|
||||
@@ -20,10 +22,13 @@
|
||||
<td class="collapsible">{{ circle.members | length }}</td>
|
||||
<td class="nowrap">
|
||||
{%- call custom_form_button("create_link", invite_form.circles.name, circle.id_, slim=True, class="secondary accent") -%}
|
||||
{% trans circle_name=circle.name %}Create invitation to circle {{ circle_name }}{% endtrans %}
|
||||
{% trans circle_name=(circle | circle_name) %}Create invitation to circle {{ circle_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call action_button("more", url_for(".edit_circle", id_=circle.id_), class="primary") -%}
|
||||
{% trans circle_name=circle.name %}Show details of circle {{ circle_name }}{% endtrans %}
|
||||
{%- call action_button("people", url_for(".edit_circle", id_=circle.id_) + "#members", class="secondary") -%}
|
||||
{% trans circle_name=(circle | circle_name) %}Manage members of {{ circle_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call action_button("edit", url_for(".edit_circle", id_=circle.id_), class="primary") -%}
|
||||
{% trans circle_name=(circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
<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>
|
||||
<div class="f-ebox">
|
||||
{{ invite_form.reusable }}
|
||||
{{ invite_form.reusable.label }}
|
||||
<fieldset>{#- -#}
|
||||
<legend>{{ invite_form.type_.label.text }}</legend>
|
||||
{{- invite_form.type_ -}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ invite_form.lifetime.label }}
|
||||
|
||||
@@ -11,17 +11,13 @@
|
||||
<dd>{{ target_user.localpart }}</dd>
|
||||
<dt>{% trans %}Display name{% endtrans %}</dt>
|
||||
<dd>{{ target_user.display_name }}</dd>
|
||||
<dt>{% trans %}Email address{% endtrans %}</dt>
|
||||
<dd>{{ target_user.email }}</dd>
|
||||
<dt>{% trans %}Display name{% endtrans %}</dt>
|
||||
<dd>{{ target_user.phone }}</dd>
|
||||
</dl>
|
||||
{% call box("alert", _("Danger")) %}
|
||||
<p>{% trans %}The user and their data will be deleted irrevocably, permanently and immediately upon pushing the below button. <strong>There is no way back!</strong>{% endtrans %}</p>
|
||||
{% endcall %}
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("back", url_for(".index"), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||
{%- call form_button("remove", form.action_delete, class="primary danger") %}{% endcall -%}
|
||||
{%- call standard_button("back", url_for(".edit_user", localpart=target_user.localpart), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
|
||||
</div>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,42 +1,79 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button %}
|
||||
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans circle_name=(target_circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %}</h1>
|
||||
<form method="POST">
|
||||
{{- 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">
|
||||
<h2 class="form-title">{% trans %}Circle information{% endtrans %}</h2>
|
||||
<div class="f-ebox">
|
||||
{{ 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="secondary") -%}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
|
||||
{% trans %}Return to circle list{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
|
||||
</div>
|
||||
<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 form_button("done", form.action_delete, class="secondary danger") %}{% endcall -%}
|
||||
{%- call form_button("delete", form.action_delete, class="secondary danger") %}{% endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
<h2>{% trans %}Circle members{% endtrans %}</h2>
|
||||
{%- endif -%}
|
||||
<h2 id="members">{% trans %}Circle members{% endtrans %}</h2>
|
||||
{%- if circle_members -%}
|
||||
<div class="el-2 elevated"><table>
|
||||
<thead>
|
||||
<th>Login name</th>
|
||||
<th class="collapsible">Display name</th>
|
||||
<th>Actions</th>
|
||||
<th>{% trans %}Login name{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Display name{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for member in circle_members -%}
|
||||
{%- for localpart, member in circle_members -%}
|
||||
<tr>
|
||||
<td>{{ member.localpart }}</td>
|
||||
<td>
|
||||
{%- if member -%}
|
||||
{{ localpart }}
|
||||
{%- else -%}
|
||||
{{ localpart }}
|
||||
<span class="with-tooltip above" data-tooltip="{% trans %}The user has been deleted from the server.{% endtrans %}"><em> ({% trans %}deleted{% endtrans %})</em></span>
|
||||
{%- endif -%}
|
||||
</td>
|
||||
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
|
||||
<td class="nowrap">
|
||||
{%- call custom_form_button("remove", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}
|
||||
{%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}
|
||||
{% trans username=member.localpart %}Remove user {{ username }} from circle{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</td>
|
||||
@@ -56,12 +93,12 @@
|
||||
<div class="select-wrap">{{ form.user_to_add }}</div>
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("add", form.action_add_user, class="primary") %}{% endcall -%}
|
||||
{%- call form_button("add_user", form.action_add_user, class="primary") %}{% endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
{%- else -%}
|
||||
<div class="box hint el-2">
|
||||
<header>{% trans %}No users left{% endtrans %}</header>
|
||||
<div class="elevated box success el-2">
|
||||
<header>{% trans %}All users added{% endtrans %}</header>
|
||||
<p>{% trans %}All users on this service are already in this circle.{% endtrans %}</p>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import showuri, form_button, standard_button, extract_circle_name %}
|
||||
{% from "library.j2" import showuri, form_button, standard_button, extract_circle_name, invite_type_description %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
@@ -12,10 +12,10 @@
|
||||
<dl>
|
||||
<dt>{% trans %}Valid until{% endtrans %}</dt>
|
||||
<dd>{{ invite.expires | format_date }}</dd>
|
||||
<dt>{% trans %}Link{% endtrans %}</dt>
|
||||
<dd>{% call showuri(invite.landing_page) %}{% endcall %}</dd>
|
||||
<dt>{% trans %}Reusability{% endtrans %}</dt>
|
||||
<dd>{% if invite.reusable %}{% trans %}This invitation link can be used arbitrarily often, until it expires, is revoked or a service-wide user limit is reached.{% endtrans %}{% else %}{% trans %}This invitation link can only be used once and is then depleted.{% endtrans %}{% endif %}</dd>
|
||||
<dt><label for="link-field">{% trans %}Link{% endtrans %}</label></dt>
|
||||
<dd>{% call showuri(invite.landing_page, id_="link-field") %}{% endcall %}</dd>
|
||||
<dt>{% trans %}Invitation type{% endtrans %}</dt>
|
||||
<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 -#}
|
||||
@@ -36,14 +36,18 @@
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
{%- endif -%}
|
||||
{%- if invite.type_.value == "roster" -%}
|
||||
<dt>{% trans %}Contact{% endtrans %}</dt>
|
||||
<dd>{% trans peer_jid=invite.jid %}The user will get added as contact of {{ peer_jid }}.{% endtrans %}</dd>
|
||||
{%- endif -%}
|
||||
<dt>{% trans %}Created{% endtrans %}</dt>
|
||||
<dd>{{ invite.created_at | format_date }}</dd>
|
||||
</dl>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%}
|
||||
{%- call standard_button("back", url_for(".invitations"), class="primary") %}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- call standard_button("back", url_for(".invitations"), class="tertiary") %}
|
||||
{% trans %}Return to invitation list{% endtrans %}
|
||||
{%- endcall %}
|
||||
{%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,32 +1,78 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import box, form_button, standard_button, icon %}
|
||||
{% macro access_level_description(role, caller=None) %}
|
||||
{%- if role == "prosody:restricted" -%}
|
||||
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
|
||||
{%- elif role == "prosody:normal" -%}
|
||||
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
|
||||
{%- elif role == "prosody:admin" -%}
|
||||
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro access_level_icon(role, caller=None) %}
|
||||
{%- if role == "prosody:restricted" -%}
|
||||
{% call icon("lock") %}{% endcall %}
|
||||
{%- elif role == "prosody:admin" -%}
|
||||
{% call icon("admin") %}{% endcall %}
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% block content %}
|
||||
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
|
||||
<div class="form layout-expanded"><form method="POST">
|
||||
<h2 class="form-title">{% trans %}User information{% endtrans %}</h2>
|
||||
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
|
||||
<h2 class="form-title">{% trans %}Edit user{% endtrans %}</h2>
|
||||
<div class="f-ebox">
|
||||
{{ form.localpart.label }}
|
||||
{{ form.localpart(readonly="readonly") }}
|
||||
<p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.display_name.label }}
|
||||
{{ form.display_name }}
|
||||
</div>
|
||||
<h3 class="form-title">{% trans %}Access Level{% endtrans %}</h3>
|
||||
<p class="form-descr weak">{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}</p>
|
||||
<div class="f-ebox">
|
||||
<fieldset>{#- -#}
|
||||
<legend class="a11y-only">{{ form.role.label.text }}</legend>
|
||||
{%- for level in form.role -%}
|
||||
<div class="radio-button-ext">
|
||||
{{ level }}<label for="{{ level.id }}">
|
||||
{%- trans title=level.label.text, icon=access_level_icon(level.data), description=access_level_description(level.data) -%}
|
||||
<strong>{{ title }}{{ icon }}</strong><p>{{ description }}</p>
|
||||
{%- endtrans -%}
|
||||
</label>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("back", url_for(".users"), class="tertiary") -%}
|
||||
{%- trans -%}Return to user list{%- endtrans -%}
|
||||
{%- endcall -%}
|
||||
{%- call standard_button("delete", url_for(".delete_user", localpart=target_user.localpart), class="secondary") -%}
|
||||
{%- trans -%}Delete user{%- endtrans -%}
|
||||
{%- endcall -%}
|
||||
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
<h2>{% trans %}Further actions{% endtrans %}</h2>
|
||||
<div class="form layout-expanded">
|
||||
<h2 class="form-title">{% trans %}Reset password{% endtrans %}</h2>
|
||||
{{ form.csrf_token }}
|
||||
<div class="f-ebox">
|
||||
{{ form.username.label }}
|
||||
{{ form.username(readonly="readonly") }}
|
||||
<p class="form-desc">
|
||||
{% trans %}If the user has lost their password, you can use the button below to create a special link which allows to change the password of the account, once.{% endtrans %}
|
||||
</p>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("passwd", form.action_create_reset, class="primary") -%}{%- endcall -%}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.nickname.label }}
|
||||
{{ form.nickname(readonly="readonly") }}
|
||||
<h2 class="form-title">{% trans %}Debug information{% endtrans %}</h2>
|
||||
<p class="form-desc">
|
||||
{% trans %}In some cases, extended information about the user account and the connected devices is necessary to troubleshoot issues. The button below reveals this (sensitive) information.{% endtrans %}
|
||||
</p>
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="primary") -%}
|
||||
{%- trans -%}Show debug information{%- endtrans -%}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.email.label }}
|
||||
{{ form.email(readonly="readonly") }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.phone.label }}
|
||||
{{ form.phone(readonly="readonly") }}
|
||||
</div>
|
||||
{{ form.action_save(class="primary") }}
|
||||
<input type="submit" class="a11y-only">
|
||||
<h2 class="form-title">{% trans %}Password reset{% endtrans %}</h2>
|
||||
<p>{% trans %}If the user has forgotten their password, use the below button to create a password reset link. The password reset link can be used once to change the password of the account. Transmit the link to the user via a secure channel.{% endtrans %}</p>
|
||||
{{ form.action_create_reset_link(class="secondary accent") }}
|
||||
<h2 class="form-title">{% trans %}Delete user{% endtrans %}</h2>
|
||||
<p>{% trans %}{% endtrans %}</p>
|
||||
{{ form.action_create_reset_link(class="secondary accent") }}
|
||||
</form></div>
|
||||
</div></form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,55 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% set body_id = "home" %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Welcome to the administration dashboard!{% endtrans %}</h1>
|
||||
<h1>{% trans %}Welcome to the admin panel!{% endtrans %}</h1>
|
||||
<p>{% trans user_name=user_info.display_name %}At your service, {{ user_name }}.{% endtrans %}</p>
|
||||
<div class="welcome-cards">
|
||||
<a class="card" href="{{ url_for('.users') }}">
|
||||
<h2>{% trans %}Manage users{% endtrans %}</h2>
|
||||
<p>{% trans %}Modify administrative user information or delete users.{% endtrans %}</p>
|
||||
</a>
|
||||
<a class="card" href="{{ url_for('.circles') }}">
|
||||
<h2>{% trans %}Manage circles{% endtrans %}</h2>
|
||||
</a>
|
||||
<a class="card" href="{{ url_for('.invitations') }}">
|
||||
<h2>{% trans %}Manage invitations{% endtrans %}</h2>
|
||||
<p>{% trans %}Create, revoke or view invitations.{% endtrans %}</p>
|
||||
</a>
|
||||
<a class="card" href="{{ url_for('user.index') }}">
|
||||
<h2>{% trans %}Back to the main view{% endtrans %}</h2>
|
||||
<p>{% trans %}Go back to your user’s web portal page.{% endtrans %}</p>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="welcome">
|
||||
<ul>
|
||||
<li>
|
||||
<h2>{% trans %}Users{% endtrans %}</h2>
|
||||
{#- -#}
|
||||
<p>{% trans %}Create password reset links or delete users.{% endtrans %}</p>
|
||||
{#- -#}
|
||||
{# <img aria-hidden="true" src="{{ url_for("static", filename="img/illus-profile.svg") }}"> #}
|
||||
{#- -#}
|
||||
<div>{% call standard_button("people", url_for(".users"), class="primary") %}{% trans %}Manage users{% endtrans %}{% endcall %}</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
<li>
|
||||
<h2>{% trans %}Circles{% endtrans %}</h2>
|
||||
{#- -#}
|
||||
<p>{% trans %}Create and manage social circles represented on your service.{% endtrans %}</p>
|
||||
{#- -#}
|
||||
<div>{% call standard_button("groups", url_for(".circles"), class="primary") %}{% trans %}Manage circles{% endtrans %}{% endcall %}</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
<li>
|
||||
<h2>{% trans %}Invitations{% endtrans %}</h2>
|
||||
{#- -#}
|
||||
<p>{% trans %}Create, revoke or copy invitations.{% endtrans %}</p>
|
||||
{#- -#}
|
||||
<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>
|
||||
{#- -#}
|
||||
<div>{% call standard_button("logout", url_for("user.index"), class="secondary") %}{% trans %}Exit admin panel{% endtrans %}{% endcall %}</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import action_button, icon, clipboard_button, form_button, custom_form_button, extract_circle_name %}
|
||||
{% 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,8 +18,8 @@
|
||||
<col/>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}Valid until{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Reusable{% endtrans %}</th>
|
||||
<th>{% trans %}Expires{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Type{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Circle{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
@@ -28,8 +28,7 @@
|
||||
{% for invite in invites %}
|
||||
<tr>
|
||||
<td>{{ (invite.expires - now) | format_timedelta(add_direction=True) }}</td>
|
||||
<td class="collapsible">{% if invite.reusable %}{% trans %}Yes{% endtrans %}{% else %}{% trans
|
||||
%}No{% endtrans %}{% endif %}</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">
|
||||
|
||||
@@ -9,19 +9,19 @@
|
||||
<form method="POST">
|
||||
{{- form.csrf_token -}}
|
||||
<div class="form layout-expanded">
|
||||
<h2 class="form-title">{% trans user_name=target_user.localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2>
|
||||
<h2 class="form-title">{% trans user_name=localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2>
|
||||
<p class="form-desc">{% trans %}The following link will allow the user to reset their password on their device, once.{% endtrans %}</p>
|
||||
<dd>
|
||||
<dt>{% trans %}Valid until{% endtrans %}</dt>
|
||||
<dd>{{ reset_link.expires | format_date }}</dd>
|
||||
<dt>{% trans %}Link{% endtrans %}</dt>
|
||||
<dd>{% call showuri(reset_link.landing_page) %}{% endcall %}</dd>
|
||||
<dt><label for="link-field">{% trans %}Link{% endtrans %}</label></dt>
|
||||
<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") -%}
|
||||
{% trans %}Destroy link{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call standard_button("back", url_for(".users"), class="primary") -%}
|
||||
{%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
|
||||
97
snikket_web/templates/admin_system.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import form_button %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Manage system{% endtrans %}</h1>
|
||||
{% if show_metrics %}
|
||||
<h2>{% trans %}Overall system status{% endtrans %}</h2>
|
||||
<div class="elevated el-2">
|
||||
<dl>
|
||||
<dt>{% trans %}System load (5 minute average){% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.load5 -%}
|
||||
{{ metrics.load5 }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
<dt>{% trans %}Memory use{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.mem_total and metrics.mem_available -%}
|
||||
{% trans percentage_global=((1 - (metrics.mem_available / metrics.mem_total)) | format_percent), percentage_snikket=((((metrics.prosody_rss | default(0)) + (metrics.portal_rss | default(0))) / metrics.mem_total) | format_percent), mem_available=(metrics.mem_total | format_bytes) %}{{ percentage_global }} of {{ mem_available }}. Of that, Snikket uses {{ percentage_snikket }}.{% endtrans %}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<h2>{% trans %}Web portal status{% endtrans %}</h2>
|
||||
<div class="elevated el-2">
|
||||
<dl>
|
||||
<dt>{% trans %}Version{% endtrans %}</dt>
|
||||
<dd>{{ version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
|
||||
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.portal_cpu -%}
|
||||
{{ metrics.portal_cpu | format_percent }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
<dt>{% trans %}Current memory use{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.portal_rss -%}
|
||||
{{ metrics.portal_rss | format_bytes }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<h2>{% trans %}Snikket server status{% endtrans %}</h2>
|
||||
<div class="elevated el-2">
|
||||
<dl>
|
||||
<dt>{% trans %}Version{% endtrans %}</dt>
|
||||
<dd>{{ prosody_version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
|
||||
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.prosody_cpu -%}
|
||||
{{ metrics.prosody_cpu | format_percent }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
<dt>{% trans %}Current memory use{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.prosody_rss -%}
|
||||
{{ metrics.prosody_rss | format_bytes }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
<dt>{% trans %}Connected devices{% endtrans %}</dt>
|
||||
<dd>
|
||||
{%- if metrics.prosody_devices | default(None) is not none -%}
|
||||
{{ metrics.prosody_devices }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}unknown{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h2>{% trans %}Broadcast message{% endtrans %}</h2>
|
||||
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
|
||||
<p class="form-desc">{% trans %}This form allows you to send a message to all users currently online on your Snikket server. Use it wisely.{% endtrans %}</p>
|
||||
<div class="f-ebox">
|
||||
{{ form.text.label }}
|
||||
{{ form.text }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.online_only }}{{ form.online_only.label }}
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call form_button("send", form.action_send_preview, class="primary") -%}{%- endcall -%}
|
||||
{%- call form_button("broadcast", form.action_post_all, class="secondary accent") -%}{%- endcall -%}
|
||||
</div>
|
||||
</div></form>
|
||||
{% endblock %}
|
||||
@@ -1,35 +1,31 @@
|
||||
{% extends "admin_app.html" %}
|
||||
{% from "library.j2" import action_button, value_or_hint, custom_form_button %}
|
||||
{% from "library.j2" import action_button, icon, value_or_hint, custom_form_button %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Manage users{% endtrans %}</h1>
|
||||
<form method="POST" action="{{ url_for(".create_password_reset_link") }}">
|
||||
{{- reset_form.csrf_token -}}
|
||||
<div class="elevated el-2"><table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}Login name{% endtrans %}</th>
|
||||
<th>{% trans %}Display name{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Email address{% endtrans %}</th>
|
||||
<th class="collapsible">{% trans %}Phone number{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.localpart }}</td>
|
||||
<td>
|
||||
{{- user.localpart -}}
|
||||
{%- if user.has_admin_role -%}
|
||||
<span class="with-tooltip above" data-tooltip="{% trans %}The user is an administrator.{% endtrans %}">{% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %}</span>
|
||||
{%- endif -%}
|
||||
{%- if user.has_restricted_role -%}
|
||||
<span class="with-tooltip above" data-tooltip="{% trans %}The user is restricted.{% endtrans %}">{% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %}</span>
|
||||
{%- endif -%}
|
||||
</td>
|
||||
<td>{% call value_or_hint(user.display_name) %}{% endcall %}</td>
|
||||
<td class="collapsible">{% call value_or_hint(user.email) %}{% endcall %}</td>
|
||||
<td class="collapsible">{% call value_or_hint(user.phone) %}{% endcall %}</td>
|
||||
<td class="nowrap">
|
||||
{%- call action_button("remove", url_for(".delete_user", localpart=user.localpart), class="secondary") -%}
|
||||
{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call action_button("bug_report", url_for(".debug_user", localpart=user.localpart), class="secondary") -%}
|
||||
{% trans user_name=user.localpart %}Show debug information for {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call custom_form_button("create_link", reset_form.action_create.name, user.localpart, class="secondary", slim=True) -%}
|
||||
{% trans user_name=user.localpart %}Create password reset link for {{ user_name }}{% endtrans %}
|
||||
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%}
|
||||
{% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</form>
|
||||
</td>
|
||||
@@ -37,5 +33,5 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</form>
|
||||
{%- include "admin_create_invite_form.html" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "unauth.html" %}
|
||||
{% from "library.j2" import avatar with context %}
|
||||
{% from "library.j2" import standard_button %}
|
||||
{% block head_lead %}
|
||||
<title>{% trans %}Snikket Web Portal{% endtrans %}</title>
|
||||
{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{{- super() -}}
|
||||
<nav class="usermenu">{{ user_info.display_name }}{% call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall %}</nav>
|
||||
{% call standard_button("logout", url_for("user.logout"), class="tertiary slimmify") %}{% trans %}Log out{% endtrans %}{% endcall %}
|
||||
{%- endblock %}
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/common.css') }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
{% endblock %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for("static", filename="img/apple-touch-icon.png") }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for("static", filename="img/favicon-32x32.png") }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for("static", filename="img/favicon-16x16.png") }}">
|
||||
<link rel="manifest" href="{{ url_for("site_manifest") }}">
|
||||
<link rel="mask-icon" href="{{ url_for("static", filename="img/safari-pinned-tab.svg") }}" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %}{% if body_class | default(False) %} class="{{ body_class }}"{% endif %}{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
|
||||
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %} class="{% if is_in_debug_mode %}debug{% endif %}{% if body_class | default(False) %} {{ body_class }}{% endif %}"{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
|
||||
</html>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% block body %}
|
||||
<h1 id="dummy">Theme Demo</h1>
|
||||
<p>This page is to demonstrate the Snikket Web Portal theme and allow development. You should not see this during normal use.</p>
|
||||
<p><a id="disable-lines" href="#no-lines" class="button secondary">Disable rhythm lines</a><a id="enable-lines" href="#dummy" class="button secondary">Enable rhythm lines</a></p>
|
||||
<p style="height: 3rem"><a id="disable-lines" href="#no-lines" class="button secondary">Disable rhythm lines</a><a id="enable-lines" href="#dummy" class="button secondary">Enable rhythm lines</a></p>
|
||||
<h2>Headings</h2>
|
||||
<p>This subsection is responsible for demonstrating the heading sizes, with the relation between the different headings and also the relation between headings and text.</p>
|
||||
<div class="demo-columns"><div class="demo-column">
|
||||
@@ -145,14 +145,20 @@
|
||||
<input type="text" id="fex-f1" name="fex-f1">
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
<input type="checkbox" id="fex-f2" name="fex-f2"><label for="fex-f2">Enable fancy features</label>
|
||||
<input type="checkbox" id="fex-f3" name="fex-f3"><label for="fex-f3">Enable more features</label>
|
||||
<input type="checkbox" id="fex-f4" name="fex-f4"><label for="fex-f4">Also do that other thing</label>
|
||||
<label>Optional features</label>
|
||||
<ul id="check-features">
|
||||
<li><input type="checkbox" id="fex-f2" name="fex-f2"><label for="fex-f2">Enable fancy features</label></li>
|
||||
<li><input type="checkbox" id="fex-f3" name="fex-f3"><label for="fex-f3">Enable more features</label></li>
|
||||
<li><input type="checkbox" id="fex-f4" name="fex-f4"><label for="fex-f4">Also do that other thing</label></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
<input type="radio" id="fex-f5" name="fex-rg1"><label for="fex-f5">High difficulty</label>
|
||||
<input type="radio" id="fex-f6" name="fex-rg1"><label for="fex-f6">Medium difficulty</label>
|
||||
<input type="radio" id="fex-f7" name="fex-rg1"><label for="fex-f7">Low difficulty</label>
|
||||
<label>Level</label>
|
||||
<ul id="radio-difficulty">
|
||||
<li><input type="radio" id="fex-f5" name="fex-rg1"><label for="fex-f5">High difficulty</label></li>
|
||||
<li><input type="radio" id="fex-f6" name="fex-rg1"><label for="fex-f6">Medium difficulty</label></li>
|
||||
<li><input type="radio" id="fex-f7" name="fex-rg1"><label for="fex-f7">Low difficulty</label></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
<label for="fex-f8">Select dropdown:</label>
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="css/invite.css") }}">
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div id="mwrap"><main>{% block content %}{% endblock %}</main></div>
|
||||
<div id="mwrap"><div class="filler"></div><main>{% block content %}{% endblock %}</main><div class="filler"></div></div>
|
||||
{%- include "_footer.html" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% extends "invite.html" %}
|
||||
{% set body_id = "invite" %}
|
||||
{% block content %}
|
||||
<div class="elevated box el-3">
|
||||
<h1>{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }}{% endtrans %}</h1>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<div class="box alert">
|
||||
<header>{% trans %}Invite expired{% endtrans %}</header>
|
||||
<p>{% trans %}Sorry, it looks like this invite code has expired!{% endtrans %}</p>
|
||||
<p>{% trans %}Sorry, it looks like this invitation link has expired!{% endtrans %}</p>
|
||||
</div>
|
||||
<img alt="Sad person holding empty box" src="{{ url_for("static", filename="img/illus-empty.svg") }}" class="fullwidth">
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% block content %}
|
||||
<div class="elevated box el-3">
|
||||
<h1>{% trans site_name=config["SITE_NAME"] %}Register on {{ site_name }}{% endtrans %}</h1>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<p>{% trans site_name=config["SITE_NAME"] %}{{ site_name }} is using Snikket - a secure, privacy-friendly chat app.{% endtrans %}</p>
|
||||
<h2>{% trans %}Create an account{% endtrans %}</h2>
|
||||
<p>{% trans %}Creating an account will allow to communicate with other people using the Snikket app or compatible software. If you already have the app installed, we recommend that you continue the account creation process inside the app by clicking on the button below:{% endtrans %}</p>
|
||||
|
||||
35
snikket_web/templates/invite_reset.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "unauth.html" %}
|
||||
{% from "library.j2" import standard_button, render_errors %}
|
||||
{% block style %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="css/invite.css") }}">
|
||||
{% endblock %}
|
||||
{% 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">
|
||||
{{- form.csrf_token -}}
|
||||
<h1 class="form-title">{% trans %}Reset your password online{% endtrans %}</h1>
|
||||
<p class="form-desc weak">{% trans %}To reset your password online, fill out the fields below and confirm using the button.{% endtrans %}</p>
|
||||
{%- call render_errors(form) %}{% endcall -%}
|
||||
<div class="f-ebox">
|
||||
{{ form.password.label }}
|
||||
{{ form.password }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.password_confirm.label }}
|
||||
{{ 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 %}
|
||||
14
snikket_web/templates/invite_reset_success.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "unauth.html" %}
|
||||
{% from "library.j2" import standard_button %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
<title>{% trans %}Password reset successful | Snikket{% endtrans %}</title>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Password reset successful{% endtrans %}</h1>
|
||||
<div class="box success">
|
||||
<header>{% trans %}Your password has been changed{% endtrans %}</header>
|
||||
<p>{% trans %}You can now log in using your new password.{% endtrans %}</p>
|
||||
<p>{% trans %}You can now safely close this page.{% endtrans %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
snikket_web/templates/invite_reset_view.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "unauth.html" %}
|
||||
{% from "library.j2" import standard_button %}
|
||||
{% block style %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="css/invite.css") }}">
|
||||
{% endblock %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
<title>{% trans %}Reset your password | Snikket{% endtrans %}</title>
|
||||
<script async type="text/javascript" src="{{ url_for("static", filename="js/invite-magic.js") }}"></script>
|
||||
<script async type="text/javascript" src="{{ url_for("static", filename="js/qrcode.min.js") }}"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Reset your password{% endtrans %}</h1>
|
||||
<p>{% trans account_jid=account_jid %}This page allows you to reset the password of your account, <strong>{{ account_jid }}</strong>, once.{% endtrans %}</p>
|
||||
<div class="elevated el-2">
|
||||
<h2>{% trans %}Using the app{% endtrans %}</h2>
|
||||
<p>{% trans %}To reset your password using the Snikket App, tap the button below.{% endtrans %}</p>
|
||||
<div>
|
||||
{%- call standard_button("exit_to_app", invite.xmpp_uri, class="secondary") -%}
|
||||
{% trans %}Open the app{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
<img class="float-right" id="tutorial-scan" aria-hidden="true" alt="" src="{{ url_for("static", filename="img/tutorial-scan.png") }}">
|
||||
<p>{% trans %}Alternatively, you can scan the below code with the Snikket App using 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>
|
||||
<p>{% trans %}You will then be prompted to enter a new password for your account.{% endtrans %}</p>
|
||||
<div id="qr-uri" data-qrdata="{{ invite.xmpp_uri }}" class="qr"></div>
|
||||
<h2>{% trans %}Alternatives{% endtrans %}</h2>
|
||||
<p>{% trans reset_url=url_for(".reset", id_=invite_id) %}You can also <a href="{{ reset_url }}">reset your password online</a> if the above button or scanning the QR code does not work for you.{% endtrans %}</p>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var onload = function() {
|
||||
apply_qr_code(document.getElementById("qr-uri"));
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -8,13 +8,13 @@
|
||||
{% block content %}
|
||||
<div class="elevated box el-3 form layout-expanded">
|
||||
<h1>{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }}{% endtrans %}</h1>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<p>{% trans site_name=config["SITE_NAME"], jid=jid %}Congratulations! You successfully registered on {{ site_name }} as {{ jid }}.{% endtrans %}</p>
|
||||
<input type="text" readonly="readonly" value="{{ jid }}">
|
||||
<label for="address" class="a11y-only">{% trans %}Your address{% endtrans %}</label><input type="text" readonly="readonly" value="{{ jid }}" id="address">
|
||||
{%- call clipboard_button(jid, show_label=True) -%}
|
||||
{% trans %}Copy address{% endtrans %}
|
||||
{%- endcall -%}
|
||||
<p>{% trans %}You can not set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p>
|
||||
<p>{% trans %}You can now safely close this page.{% endtrans %}</p>
|
||||
<p>{% trans %}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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,25 +6,33 @@
|
||||
<title>{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }} | Snikket{% endtrans %}</title>
|
||||
<script async type="text/javascript" src="{{ url_for("static", filename="js/invite-magic.js") }}"></script>
|
||||
<script async type="text/javascript" src="{{ url_for("static", filename="js/qrcode.min.js") }}"></script>
|
||||
<link rel="alternate" href="{{ invite.xmpp_uri }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="elevated box el-3">
|
||||
<h1>{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }}{% endtrans %}</h1>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
<div class="powered-by">{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by <img src="{{ logo_url }}" alt="Snikket">{% endtrans %}</div>
|
||||
{%- if invite.inviter -%}
|
||||
<p>{% trans site_name=config["SITE_NAME"], inviter_name=invite.inviter %}You have been invited to chat with {{ inviter_name }} using Snikket, a secure, privacy-friendly chat app on {{ site_name }}.{% endtrans %}</p>
|
||||
{%- 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 -%}
|
||||
<h2>{% trans %}Get started{% endtrans %}</h2>
|
||||
{%- if apple_store_url -%}
|
||||
<p>{% trans %}Install the Snikket App on your Android or iOS device.{% endtrans %}</p>
|
||||
{%- else -%}
|
||||
<p>{% trans ios_info_url="https://snikket.org/faq/#is-there-an-ios-app" %}Install the Snikket App on your Android device (<a href="{{ ios_info_url }}" rel="noopener noreferrer" target="_blank">iOS coming soon!</a>).{% endtrans %}</p>
|
||||
{%- endif -%}
|
||||
<div class="install-buttons">
|
||||
<ul>
|
||||
<li><a href="{{ play_store_url }}"><img alt='{% trans %}Get it on Google Play{% endtrans %}' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' class="play"/></a></li>
|
||||
<li><a href="{{ apple_store_url }}"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></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 -%}
|
||||
<li><a href="{{ f_droid_url }}" class="popover" data-popover-id="fdroid-popover"><img alt='{% trans %}Get it on F-Droid{% endtrans %}' src='{{ url_for('static', filename='img/f-droid-badge.png') }}' class="fdroid"/></a></li>
|
||||
</ul>
|
||||
{%- call standard_button("qrcode", "#qr-modal", class="primary", onclick="open_modal(this); return false;") -%}
|
||||
{% trans %}Not on mobile?{% endtrans %}
|
||||
{% trans %}Send to mobile device{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
<p>{% trans %}After installation the app should automatically open and prompt you to create an account. If not, simply click the button below.{% endtrans %}</p>
|
||||
@@ -60,7 +68,7 @@
|
||||
{#- -#}
|
||||
<div id="qr-info-url" class="tab-pane active">
|
||||
<p>{% trans %}Use a <em>QR code</em> scanner on your mobile device to scan the code below:{% endtrans %}</p>
|
||||
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True) }}" class="qr"></div>
|
||||
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True, _scheme="https") }}" class="qr"></div>
|
||||
</div>
|
||||
{#- -#}
|
||||
<div id="qr-info-uri" class="tab-pane">
|
||||
@@ -77,10 +85,77 @@
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
{%- if apple_store_url -%}
|
||||
<div id="apple-popover" class="modal" tabindex="-1" role="dialog" aria-hidden="true" style="display: none;" onclick="close_modal(this); return false;">
|
||||
<div role="document" class="elevated box el-2" onclick="event.stopPropagation();">
|
||||
<header class="modal-title">
|
||||
{#- -#}
|
||||
<span>{% trans %}Install on iOS{% endtrans %}</span>
|
||||
{#- -#}
|
||||
{%- call action_button("close", "#", onclick="close_modal(this.parentNode.parentNode.parentNode); return false;", class="tertiary") -%}
|
||||
{% trans %}Close{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</header>
|
||||
<p>{% trans %}After downloading Snikket from the App Store, you have to return to this invite link and tap on "Open the app" to proceed.{% endtrans %}</p>
|
||||
<ol>
|
||||
<li><p>{% trans %}First download Snikket from the App Store using the button below:{% endtrans %}</p>
|
||||
<p><a href="{{ apple_store_url }}"><img alt='{% trans %}Download on the App Store{% endtrans %}' src="{{ apple_store_badge() }}" class="apple"></a></p>
|
||||
<li><p>{% trans %}After the installation is complete, you can return to this page and tap the "Open the app" button to continue with the setup:{% endtrans %}</p>
|
||||
<p>
|
||||
{%- call standard_button("exit_to_app", invite.xmpp_uri, class="primary") -%}
|
||||
{% trans %}Open the app{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</p></li>
|
||||
</ol>
|
||||
{#- -#}
|
||||
{%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="secondary") -%}
|
||||
{% trans %}Close{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
<div id="fdroid-popover" class="modal" tabindex="-1" role="dialog" aria-hidden="true" style="display: none;" onclick="close_modal(this); return false;">
|
||||
<div role="document" class="elevated box el-2" onclick="event.stopPropagation();">
|
||||
<header class="modal-title">
|
||||
{#- -#}
|
||||
<span>{% trans %}Install via F-Droid{% endtrans %}</span>
|
||||
{#- -#}
|
||||
{%- call action_button("close", "#", onclick="close_modal(this.parentNode.parentNode.parentNode); return false;", class="tertiary") -%}
|
||||
{% trans %}Close{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</header>
|
||||
<p>{% trans %}After installing Snikket via F-Droid, you have to return to this invite link and tap on "Open the app" to proceed.{% endtrans %}</p>
|
||||
<ol>
|
||||
<li><p>{% trans %}First install Snikket from F-Droid using the button below:{% endtrans %}</p>
|
||||
<p><a href="{{ f_droid_url }}"><img alt='{% trans %}Install via F-Droid{% endtrans %}' src='{{ url_for('static', filename='img/f-droid-badge.png') }}' class="fdroid"/></a></p></li>
|
||||
<li><p>{% trans %}After the installation is complete, you can return to this page and tap the "Open the app" button to continue with the setup:{% endtrans %}</p>
|
||||
<p>
|
||||
{%- call standard_button("exit_to_app", invite.xmpp_uri, class="primary") -%}
|
||||
{% trans %}Open the app{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</p></li>
|
||||
</ol>
|
||||
{#- -#}
|
||||
{%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="secondary") -%}
|
||||
{% trans %}Close{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var catch_popover = function() {
|
||||
open_modal(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
var onload = function() {
|
||||
apply_qr_code(document.getElementById("qr-invite-page"));
|
||||
apply_qr_code(document.getElementById("qr-uri"));
|
||||
var popover_as = document.getElementsByClassName("popover");
|
||||
for (var i = 0; i < popover_as.length; ++i) {
|
||||
var a = popover_as[i];
|
||||
a.onclick = catch_popover;
|
||||
a.href = "#" + a.dataset.popoverId;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro showuri(uri, caller=None) %}
|
||||
{% macro showuri(uri, caller=None, id_=None) %}
|
||||
{%- if uri is none -%}
|
||||
<em>—</em>
|
||||
{%- else -%}
|
||||
<div><input type="text" readonly="readonly" value="{{ uri }}"></div>
|
||||
<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 %}</div>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
{% macro standard_button(icon_name, href, caller=None, class=None, onclick=None) -%}
|
||||
{%- set label = caller() -%}
|
||||
<a href="{{ href }}" class="button {% if class %}{{ class }}{% endif %}" aria-label="{{ a11y }}" title="{{ a11y }}"{% if onclick %} onclick="{{ onclick }}"{% endif %}>{% call icon(icon_name) %}{% endcall %}<span>{{ label }}</span></a>
|
||||
<a href="{{ href }}" class="button {% if class %}{{ class }}{% endif %}" {% if onclick %} onclick="{{ onclick }}"{% endif %}>{% call icon(icon_name) %}{% endcall %}<span>{{ label }}</span></a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro form_button(icon_name, button_obj, caller=None, class=None) -%}
|
||||
@@ -60,6 +60,7 @@
|
||||
{% macro clipboard_button(data, show_label=False, caller=None, class=None) -%}
|
||||
{%- set label = caller() -%}
|
||||
<a class="button{% if class %} {{ class }}{% endif %}"
|
||||
href="#"
|
||||
{% if not show_label %}
|
||||
aria-label="{{ label }}"
|
||||
title="{{ label }}"
|
||||
@@ -79,7 +80,7 @@
|
||||
<div class="box warning">{#- -#}
|
||||
<header>{% trans %}Invalid input{% endtrans %}</header>
|
||||
{%- if error_list | length == 1 -%}
|
||||
<p>{{ error_list[0] }}.</p>
|
||||
<p>{{ error_list[0] }}</p>
|
||||
{%- else -%}
|
||||
<ul>
|
||||
{%- for error in error_list -%}
|
||||
@@ -107,3 +108,19 @@
|
||||
<em>{% trans %}deleted{% endtrans %}</em>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{%- macro invite_type_name(invite_info, caller=None) -%}
|
||||
{%- if invite_info.reusable -%}
|
||||
{% trans %}Group{% endtrans %}
|
||||
{%- else -%}
|
||||
{% trans %}Individual{% endtrans %}
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- 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 -%}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "library.j2" import box, form_button %}
|
||||
{% from "library.j2" import box, form_button, render_errors %}
|
||||
{% set body_id = "login" %}
|
||||
{% block head_lead %}
|
||||
<title>{{ _("Snikket Login") }}</title>
|
||||
@@ -9,16 +9,16 @@
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div id="mwrap"><main><div class="form layout-expanded">
|
||||
<div id="mwrap"><div class="filler"></div><main><div class="form layout-expanded">
|
||||
<h1 class="form-title">{{ config["SITE_NAME"] }}</h1>
|
||||
<p class="form-desc">{{ _("Enter your Snikket address and password to manage your account.") }}</p>
|
||||
<form method="POST" action="{{ url_for('.login') }}" name="login">
|
||||
<form method="POST" action="{{ url_for('.login') }}" name="login" id="login-form" onsubmit="return domainCheck();" data-addressid="{{ form.address.id }}" data-domain="{{ config["SNIKKET_DOMAIN"] }}">
|
||||
{{ form.csrf_token }}
|
||||
{% if form.errors %}
|
||||
{% call box("alert", _("Login failed")) %}
|
||||
<p>{{ form.errors.values() | flatten | join(", ")}}</p>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
<div class="box alert" role="alert" style="display: none;" id="id-warning">
|
||||
<header>{% trans %}Incorrect address{% endtrans %}</header>
|
||||
<p>{% trans snikket_domain=config["SNIKKET_DOMAIN"] %}This Snikket service only hosts addresses ending in <em>@{{ snikket_domain }}</em>. Your password was not sent.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.address.label(class="a11y-only") }}
|
||||
{{ form.address(placeholder=form.address.label.text) }}
|
||||
@@ -31,8 +31,22 @@
|
||||
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
|
||||
</div>
|
||||
</from>
|
||||
</div></main></div>
|
||||
<footer>
|
||||
<ul><li>{% trans about_url=url_for('.about') %}A <a href="{{ about_url }}">Snikket</a> service{% endtrans %}</li></ul>
|
||||
</footer>
|
||||
<script type="text/javascript">
|
||||
var domainCheck = function() {
|
||||
var form = document.getElementById("login-form");
|
||||
var addressId = form.dataset.addressid;
|
||||
var addressField = document.getElementById(addressId);
|
||||
var domain = form.dataset.domain;
|
||||
var address = addressField.value;
|
||||
var errorBox = document.getElementById("id-warning");
|
||||
if (address.includes("@") && !address.endsWith(domain)) {
|
||||
errorBox.style.display = "block";
|
||||
return false;
|
||||
}
|
||||
errorBox.style.display = "none";
|
||||
return true;
|
||||
};
|
||||
</script>
|
||||
</div></main><div class="filler"></div></div>
|
||||
{%- include "_footer.html" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,25 @@
|
||||
<div class="filler"></div>
|
||||
{% block topbar_right %}{% endblock %}
|
||||
</div>
|
||||
<div id="mwrap"><main>{% block content %}{% endblock %}</main></div>
|
||||
<div id="mwrap">
|
||||
{#- -#}
|
||||
<div class="flashbox" id="flashbox">
|
||||
{%- for category, message in get_flashed_messages(True) -%}
|
||||
<div class="box {{ category }} el-5" role="alert">
|
||||
{% if category == "success" %}
|
||||
<header>{% trans %}Operation successful{% endtrans %}</header>
|
||||
{% elif category == "alert" %}
|
||||
<header>{% trans %}Error{% endtrans %}</header>
|
||||
{% endif %}
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{#- -#}
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
{#- -#}
|
||||
<div class="filler"></div>
|
||||
{#- -#}
|
||||
</div>
|
||||
{%- include "_footer.html" -%}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import clipboard_button, standard_button, avatar with context %}
|
||||
{% set body_id = "home" %}
|
||||
{% block head_lead %}
|
||||
{{ super() }}
|
||||
{% include "copy-snippet.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Welcome!{% endtrans %}</h1>
|
||||
<p>{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}</p>
|
||||
<div class="welcome-cards">
|
||||
<a class="card" href="{{ url_for('.profile') }}">
|
||||
<h2>{% trans %}Update profile{% endtrans %}</h2>
|
||||
<p>{% trans %}Change display name, set avatar and configure visibility of your personal data to others.{% endtrans %}</p>
|
||||
</a>
|
||||
<a class="card" href="{{ url_for('.change_pw') }}">
|
||||
<h2>{% trans %}Change password{% endtrans %}</h2>
|
||||
</a>
|
||||
{% if user_info.is_admin %}
|
||||
<a class="card" href="{{ url_for('admin.index') }}">
|
||||
<h2>{% trans %}Admin dashboard{% endtrans %}</h2>
|
||||
<p>{% trans %}Manage users and invitations of this Snikket service.{% endtrans %}</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="card" href="{{ url_for('.logout') }}">
|
||||
<h2>{% trans %}Log out{% endtrans %}</h2>
|
||||
<p>{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}</p>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="welcome">
|
||||
<ul>
|
||||
<li class="wide">
|
||||
<h2>{% trans %}Your account{% endtrans %}</h2>
|
||||
<div class="profile-card">
|
||||
<div class="picture" aria-label="Your profile picture">{% call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall %}</div>
|
||||
<div class="details">
|
||||
<div class="display-name">{{ user_info.display_name | default(user_info.username) }}</div>
|
||||
<div class="address">
|
||||
<input value="{{ user_info.address }}" aria-label="{% trans %}Your XMPP address{% endtrans %}">
|
||||
{% call clipboard_button(user_info.address) %}{% trans %}Copy address{% endtrans %}{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#- -#}
|
||||
{# <p>{% trans %}Change your display name, set a profile picture and control visibility of your personal data to others.{% endtrans %}</p> #}
|
||||
{#- -#}
|
||||
{# <img aria-hidden="true" src="{{ url_for("static", filename="img/illus-profile.svg") }}"> #}
|
||||
{#- -#}
|
||||
<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>
|
||||
{#- -#}
|
||||
</li>
|
||||
{% if user_info.is_admin %}
|
||||
<li>
|
||||
<h2>{% trans %}Your Snikket{% endtrans %}</h2>
|
||||
{#- -#}
|
||||
<p>{% trans %}Manage users, invitations and circles of your Snikket service.{% endtrans %}</p>
|
||||
{#- -#}
|
||||
<div>{% call standard_button("admin", url_for("admin.index"), class="primary") %}{% trans %}Admin panel{% endtrans %}{% endcall %}</div>
|
||||
{#- -#}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, form_button %}
|
||||
{% block head_lead %}
|
||||
<title>Snikket Web Portal</title>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form layout-expanded"><form method="POST">
|
||||
<h2 class="form-title">{% trans %}Sign out of the Snikket Web Portal{% endtrans %}</h2>
|
||||
<p class="form-desc">{% trans %}Click below to log yourself out of the web portal. This does not affect any other connected devices.{% endtrans %}</p>
|
||||
{{ form.csrf_token }}
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("back", url_for("user.index"), class="secondary") -%}
|
||||
{%- call standard_button("back", url_for("user.index"), class="tertiary") -%}
|
||||
{% trans %}Back{% endtrans %}
|
||||
{%- endcall -%}
|
||||
{%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, custom_form_button, render_errors %}
|
||||
{% block head_lead %}
|
||||
<title>Snikket Web Portal</title>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form layout-expanded"><form method="POST">
|
||||
<h2 class="form-title">{% trans %}Change your password{% endtrans %}</h2>
|
||||
<h1 class="form-title">{% trans %}Change your password{% endtrans %}</h1>
|
||||
<p class="form-desc weak">{% trans %}To change your password, you need to provide the current password as well as the new one. To reduce the chance of typos, we ask for your new password twice.{% endtrans %}</p>
|
||||
{{ form.csrf_token }}
|
||||
{%- call render_errors(form) -%}
|
||||
@@ -27,8 +24,8 @@
|
||||
<p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||
{%- call custom_form_button("done", "", "", class="primary") -%}
|
||||
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||
{%- call custom_form_button("passwd", "", "", class="primary") -%}
|
||||
{% trans %}Change password{% endtrans %}
|
||||
{%- endcall -%}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
{% extends "app.html" %}
|
||||
{% from "library.j2" import standard_button, form_button %}
|
||||
{% block head_lead %}
|
||||
<title>Snikket Web Portal</title>
|
||||
{% endblock %}
|
||||
{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %}
|
||||
{% block content %}
|
||||
<h1>{% trans %}Update your profile{% endtrans %}</h1>
|
||||
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
|
||||
<h2 class="form-title">{% trans %}Profile{% endtrans %}</h2>
|
||||
{{ form.csrf_token }}
|
||||
{% call render_errors(form) %}{% endcall %}
|
||||
<div class="f-ebox">
|
||||
{{ form.nickname.label }}
|
||||
{{ form.nickname(placeholder=user_info.username) }}
|
||||
</div>
|
||||
<div class="f-ebox">
|
||||
{{ form.avatar.label }}
|
||||
{{ form.avatar }}
|
||||
<div class="avatar-wrap">
|
||||
{%- call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall -%}
|
||||
{{ form.avatar(accept="image/png",
|
||||
data_maxsize=max_avatar_size,
|
||||
data_warning_header=avatar_too_big_warning_header,
|
||||
data_maxsize_warning=avatar_too_big_warning) }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="form-title">{% trans %}Visibility{% endtrans %}</h3>
|
||||
<p class="form-descr weak">{% trans %}This section allows you to control who can see your profile information, like avatar and nickname.{% endtrans %}</p>
|
||||
<div class="f-ebox">
|
||||
{{ form.profile_access_model.label }}
|
||||
{{ form.profile_access_model }}
|
||||
<fieldset>{#- -#}
|
||||
<legend>{{ form.profile_access_model.label.text }}</legend>
|
||||
{{- form.profile_access_model -}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="f-bbox">
|
||||
{%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
|
||||
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.getElementById("{{ form.avatar.id }}").onchange = function() {
|
||||
var maxsize_s = this.dataset.maxsize;
|
||||
var maxsize = parseInt(maxsize_s);
|
||||
var existing_alert = document.getElementById("avatar-alert");
|
||||
if (existing_alert) {
|
||||
existing_alert.parentNode.removeChild(existing_alert);
|
||||
}
|
||||
if (this.files[0].size > maxsize) {
|
||||
var warning_header = this.dataset.warningHeader;
|
||||
var warning_text = this.dataset.maxsizeWarning;
|
||||
this.setCustomValidity(warning_text);
|
||||
this.reportValidity();
|
||||
this.value = null;
|
||||
} else {
|
||||
this.setCustomValidity("");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
|
||||
BIN
snikket_web/translations/da/LC_MESSAGES/messages.mo
Normal file
1552
snikket_web/translations/da/LC_MESSAGES/messages.po
Normal file
BIN
snikket_web/translations/de/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/en/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/en_GB/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/es_MX/LC_MESSAGES/messages.mo
Normal file
1518
snikket_web/translations/es_MX/LC_MESSAGES/messages.po
Normal file
BIN
snikket_web/translations/fr/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/id/LC_MESSAGES/messages.mo
Normal file
BIN
snikket_web/translations/it/LC_MESSAGES/messages.mo
Normal file
1572
snikket_web/translations/it/LC_MESSAGES/messages.po
Normal file
BIN
snikket_web/translations/pl/LC_MESSAGES/messages.mo
Normal file
1594
snikket_web/translations/ru/LC_MESSAGES/messages.po
Normal file
BIN
snikket_web/translations/sv/LC_MESSAGES/messages.mo
Normal file
1551
snikket_web/translations/sv/LC_MESSAGES/messages.po
Normal file
@@ -2,21 +2,27 @@ import asyncio
|
||||
import typing
|
||||
|
||||
import quart.flask_patch
|
||||
from quart import Blueprint, render_template, request, redirect, url_for
|
||||
from quart import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
request,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
current_app,
|
||||
)
|
||||
import quart.exceptions
|
||||
|
||||
import wtforms
|
||||
|
||||
import flask_wtf
|
||||
|
||||
from flask_babel import lazy_gettext as _l, _
|
||||
|
||||
from .infra import client
|
||||
from .infra import client, BaseForm
|
||||
|
||||
bp = Blueprint('user', __name__)
|
||||
|
||||
|
||||
class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class ChangePasswordForm(BaseForm):
|
||||
current_password = wtforms.PasswordField(
|
||||
_l("Current password"),
|
||||
validators=[wtforms.validators.InputRequired()]
|
||||
@@ -32,12 +38,12 @@ class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
||||
validators=[wtforms.validators.InputRequired(),
|
||||
wtforms.validators.EqualTo(
|
||||
"new_password",
|
||||
_l("The new passwords must match")
|
||||
_l("The new passwords must match.")
|
||||
)]
|
||||
)
|
||||
|
||||
|
||||
class LogoutForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class LogoutForm(BaseForm):
|
||||
action_signout = wtforms.SubmitField(
|
||||
_l("Sign out"),
|
||||
)
|
||||
@@ -50,7 +56,7 @@ _ACCESS_MODEL_CHOICES = [
|
||||
]
|
||||
|
||||
|
||||
class ProfileForm(flask_wtf.FlaskForm): # type:ignore
|
||||
class ProfileForm(BaseForm):
|
||||
nickname = wtforms.TextField(
|
||||
_l("Display name"),
|
||||
)
|
||||
@@ -65,7 +71,7 @@ class ProfileForm(flask_wtf.FlaskForm): # type:ignore
|
||||
)
|
||||
|
||||
action_save = wtforms.SubmitField(
|
||||
_l("Apply"),
|
||||
_l("Update profile"),
|
||||
)
|
||||
|
||||
|
||||
@@ -90,17 +96,29 @@ async def change_pw() -> typing.Union[str, quart.Response]:
|
||||
quart.exceptions.Forbidden):
|
||||
# server refused current password, set an appropriate error
|
||||
form.current_password.errors.append(
|
||||
_("Incorrect password"),
|
||||
_("Incorrect password."),
|
||||
)
|
||||
else:
|
||||
await flash(
|
||||
_("Password changed"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("user.change_pw"))
|
||||
|
||||
return await render_template("user_passwd.html", form=form)
|
||||
|
||||
|
||||
EAVATARTOOBIG = _l(
|
||||
"The chosen avatar is too big. To be able to upload larger "
|
||||
"avatars, please use the app."
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/profile", methods=["GET", "POST"])
|
||||
@client.require_session()
|
||||
async def profile() -> typing.Union[str, quart.Response]:
|
||||
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
|
||||
|
||||
form = ProfileForm()
|
||||
if request.method != "POST":
|
||||
user_info = await client.get_user_info()
|
||||
@@ -114,26 +132,40 @@ async def profile() -> typing.Union[str, quart.Response]:
|
||||
if form.validate_on_submit():
|
||||
user_info = await client.get_user_info()
|
||||
|
||||
ok = True
|
||||
file_info = (await request.files).get(form.avatar.name)
|
||||
if file_info is not None:
|
||||
mimetype = file_info.mimetype
|
||||
data = file_info.stream.read()
|
||||
if len(data) > 0:
|
||||
if len(data) > max_avatar_size:
|
||||
print(len(data), max_avatar_size)
|
||||
form.avatar.errors.append(EAVATARTOOBIG)
|
||||
ok = False
|
||||
elif len(data) > 0:
|
||||
await client.set_user_avatar(data, mimetype)
|
||||
|
||||
if user_info.get("nickname") != form.nickname.data:
|
||||
await client.set_user_nickname(form.nickname.data)
|
||||
if ok:
|
||||
if user_info.get("nickname") != form.nickname.data:
|
||||
await client.set_user_nickname(form.nickname.data)
|
||||
|
||||
access_model = form.profile_access_model.data
|
||||
await asyncio.gather(
|
||||
client.set_avatar_access_model(access_model),
|
||||
client.set_vcard_access_model(access_model),
|
||||
client.set_nickname_access_model(access_model),
|
||||
)
|
||||
access_model = form.profile_access_model.data
|
||||
await asyncio.gather(
|
||||
client.set_avatar_access_model(access_model),
|
||||
client.set_vcard_access_model(access_model),
|
||||
client.set_nickname_access_model(access_model),
|
||||
)
|
||||
|
||||
return redirect(url_for(".profile"))
|
||||
await flash(
|
||||
_("Profile updated"),
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for(".profile"))
|
||||
|
||||
return await render_template("user_profile.html", form=form)
|
||||
return await render_template("user_profile.html",
|
||||
form=form,
|
||||
max_avatar_size=max_avatar_size,
|
||||
avatar_too_big_warning_header=_l("Error"),
|
||||
avatar_too_big_warning=EAVATARTOOBIG)
|
||||
|
||||
|
||||
@bp.route("/logout", methods=["GET", "POST"])
|
||||
@@ -142,6 +174,12 @@ async def logout() -> typing.Union[quart.Response, str]:
|
||||
form = LogoutForm()
|
||||
if form.validate_on_submit():
|
||||
await client.logout()
|
||||
# No flashing here because we don’t collect flashes in the login page
|
||||
# and it’d be weird.
|
||||
# await flash(
|
||||
# _("Logged out"),
|
||||
# "success",
|
||||
# )
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
return await render_template("user_logout.html", form=form)
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
action/account_circle:profile
|
||||
action/bug_report:bug_report
|
||||
action/done:done
|
||||
action/delete:delete
|
||||
action/logout:logout
|
||||
action/login:login
|
||||
action/exit_to_app:exit_to_app
|
||||
action/lock:lock
|
||||
communication/qr_code:qrcode
|
||||
communication/vpn_key:passwd
|
||||
communication/rss_feed:broadcast
|
||||
content/add_circle_outline:add
|
||||
content/add_link:create_link
|
||||
content/create:edit
|
||||
content/remove_circle_outline:remove
|
||||
content/content_copy:copy
|
||||
content/link_off:remove_link
|
||||
content/send:send
|
||||
navigation/arrow_back:back
|
||||
navigation/arrow_forward:forward
|
||||
navigation/cancel:cancel
|
||||
navigation/more_vert:more
|
||||
social/groups:groups
|
||||
social/people:people
|
||||
social/group_add:create_group
|
||||
social/person_add:add_user
|
||||
social/person_remove:remove_user
|
||||
navigation/close:close
|
||||
image/edit:edit
|
||||
action/admin_panel_settings:admin
|
||||
content/link:link
|
||||
content/insights:insights
|
||||
|
||||