Compare commits

...

154 Commits

Author SHA1 Message Date
Jonas Schäfer
40562d16f6 Fix Link header on invite pages
Thanks @singpolyma.
2023-03-28 19:19:43 +02:00
Jonas Schäfer
48a4a8f587 Merge pull request #147 from snikket-im/fix/i18n-lint
Fix i18n CI linting
2023-03-28 19:19:35 +02:00
Jonas Schäfer
664112bf53 Fix i18n CI linting
It diffs the things, and we're in 2023 now.
2023-03-28 19:16:27 +02:00
Jonas Schäfer
2dfc39e757 Merge pull request #141 from snikket-im/fix/quart-dep
Update WTForms and pin quart version
2023-03-28 19:14:30 +02:00
Jonas Schäfer
31b743a97f Update WTForms and pin quart version
The quart pin is required to fix an attribute error which otherwise
occurs during startup. Hence, this should be a good qualifier to know
when it's safe to upgrade.

Note that this is not a problem in Quart, but in flask-WTForms. But
downgrading flask-wtforms does not help [1], so we don't revert that
uprade.

```
AttributeError: module 'quart.json' has no attribute 'JSONEncoder'
```

   [1]: https://github.com/pallets/quart/issues/163
2022-10-24 10:14:03 +02:00
Weblate
14a335bb06 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/
2022-06-07 20:34:03 +00:00
Jonas Schäfer
6c8c213a88 Merge pull request #100 from snikket-im/feature/drop-xmpp-qr-code
Remove XMPP URI QR code
2022-06-07 22:33:53 +02:00
Jonas Schäfer
2e224d96ce Remove XMPP URI QR code
At the same time, we can also drop the CSS used for that makeshift tab
box. I always felt a bit uneasy about it, a11y-wise, so it's good
riddance.

Fixes #99.
2022-06-07 22:31:40 +02:00
Weblate
b70cb57497 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/
2022-06-06 17:55:08 +00:00
Jonas Schäfer
124e0ce145 Merge pull request #137 from snikket-im/premerge
Merge a bunch of things together
2022-06-06 19:54:54 +02:00
Jonas Schäfer
f2c79044e0 Clean up post-merge lint
I am a *bit* sorry for this commit, because ideally this would've been
folded into 6d50b1c2c7 and whatever the
source of the other "conflict" was.

However, as the things have been merged in a batch, I can't do much more
than this.
2022-06-06 19:52:24 +02:00
Jonas Schäfer
13bc4bb227 Merge branch 'fix/babel-extraction' into premerge 2022-06-06 19:46:59 +02:00
Jonas Schäfer
f1351eb5cc Merge branch 'fix/use-english-default' into premerge 2022-06-06 19:46:49 +02:00
Jonas Schäfer
41573569af Merge branch 'feature/export-back-button' into premerge 2022-06-06 19:46:43 +02:00
Jonas Schäfer
b1f3026b8a Merge branch 'feature/wtforms-3' into premerge 2022-06-06 19:46:32 +02:00
Jonas Schäfer
6794314a59 Merge branch 'feature/vary-accept-language' into premerge 2022-06-06 19:46:25 +02:00
Jonas Schäfer
077e957a00 Merge branch 'feature/ci-extract-translations' into premerge 2022-06-06 19:46:19 +02:00
Jonas Schäfer
4902941145 Merge branch 'feature/strip-versions' into premerge 2022-06-06 19:46:09 +02:00
Jonas Schäfer
5222c8eafe Merge branch 'feature/hypercorn-stdout' into premerge 2022-06-06 19:44:39 +02:00
Jonas Schäfer
03ca7ac5bb Unbreak translation text extraction
It was broken because of the same jinja2 update (presumably) which
prompted 68f72743c5.
2022-05-30 20:51:37 +02:00
Matthew Wild
56cee8bab6 Merge pull request #135 from snikket-im/feature/update-dependencies
Update dependencies
2022-05-30 16:59:22 +01:00
Jonas Schäfer
b36fc0d5ac Bump hsluv to 5.x
Fixes #134.
2022-05-30 17:38:18 +02:00
Jonas Schäfer
68f72743c5 Bump quart to version 0.17
This is needed because jinja2 had an update which caused the portal to
not work at all:

```
ImportError: cannot import name 'escape' from 'jinja2'
```

Quart needed updating for that.

This update required a lot of typefixes. Apparently, the "canned"
responses (like redirect) are now plain werkzeug responses, while
quart.Response does not inherit from werkzeug.Response (otherwise, we
could've changed the type annotations to werkzeug.Response everywhere,
but that doesn't work because a quart.Response is not a
werkzeug.Response).

P.S.: This time, I *did* check that avatar uploads don't break (see
b007afc).
2022-05-30 17:37:54 +02:00
Daniel Holmgaard
8741efb2c4 Translated using Weblate (Danish)
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/da/
2022-05-30 14:01:08 +00:00
Daniel Holmgaard
a0e8933b64 Translated using Weblate (Danish)
Currently translated at 95.6% (307 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/da/
2022-05-26 23:01:53 +00:00
Zack Zhou
edb3154127 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/zh_Hans/
2022-05-20 00:01:45 +00:00
Jonas Schäfer
eb22688302 Use english as default language instead of danish
It is more likely that a user for whose language no translation exists
can read english than danish.

The fallback to english was apparently introduced in c58ce845, though it
is possible that `best_match` did that internally before.

Fixes #131.
2022-05-15 14:12:51 +02:00
Jonas Schäfer
c278d4ace9 Merge pull request #132 from Raka-loah/master
Add Simplified Chinese support
2022-05-15 08:14:08 +02:00
Raka-loah
bbfe8624ef Add Simplified Chinese support 2022-05-14 17:53:40 +08:00
David Baraniak
8bcf619cef Translated using Weblate (French)
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2022-04-11 13:00:44 +00:00
Kim Alvefur
73fda3d623 Add a Back button from export panel for consistency
The other user related sections all have a Back button so it makes sense
that this one ought to have one as well.
2022-02-19 16:28:38 +01:00
Matthew Wild
846a5e49fd Merge pull request #122 from snikket-im/feature/autocomplete-hints
Add autocomplete hints to password forms
2022-02-07 11:43:20 +00:00
Matthew Wild
b3ff7f04b5 Merge pull request #127 from snikket-im/fix/unhealthy
Install netcat in final image instead of build image
2022-02-07 11:42:40 +00:00
Jonas Schäfer
0ac4ab8142 Install netcat in final image instead of build image
`nc` (from netcat) is required for the healthcheck. In
c1cf6ab1e5, the installation was
erroneously moved to the builder image, instead of the final image, so
it was missing since then from the actual application image, causing it
to always show as unhealthy.

Fixes #126.
2022-02-05 13:51:30 +01:00
Matthew Wild
d4a38f5049 Merge pull request #125 from snikket-im/fix/support-requirements-compilation
Dockerfile: Add dev headers required for building deps
2022-02-01 09:09:50 +00:00
Matthew Wild
344a4d3e93 Dockerfile: Add dev headers required for building deps 2022-02-01 09:04:25 +00:00
Matthew Wild
57f1047526 Merge pull request #124 from snikket-im/fix/support-requirements-compilation
Dockerfile: Ensure a compiler is available while pip-installing requirements
2022-02-01 08:16:33 +00:00
Matthew Wild
b036caa85e Dockerfile: Ensure a compiler is available while pip-installing requirements
Dependencies are not necessarily packaged for all architectures. In some cases
(such as aiohttp, and others, on ARM) pip will attempt to compile the
dependency from scratch. Since switching to multi-stage builds, we have been
installing these without a compiler present which caused the build to fail on
ARM architectures.

This commit temporarily installs build-essential packages while running pip,
then removes them again afterwards.
2022-01-31 21:45:39 +00:00
Matthew Wild
08845cb9f0 Merge pull request #123 from snikket-im/hotfix/error-handling
Fix error handling when building image
2022-01-31 18:11:58 +00:00
Jonas Schäfer
6aa6e12680 Fix error handling when building image
Apparently, we managed to publish an image without working aiohttp
because of this.

Partially a regression from 5d7183a.
2022-01-31 19:09:03 +01:00
Jonas Schäfer
4bd58c1104 Add autocomplete hints to password forms
This allows user agents to do smart things like filling in the current
password only where it makes sense or integrate nicely with a password
manager.

Fixes #94.
2022-01-22 15:34:27 +01:00
Jonas Schäfer
a998348804 Make hypercorn log to stdout in Docker
This may help with debugging issues, because we now actually see
incoming requests also on the hypercorn layer.

Fixes #97.
2022-01-22 15:20:36 +01:00
Jonas Schäfer
20abe4b903 Add Vary: Accept-Language to all pages using that information
It was found during testing that some user agents cache aggressively
even between switches of the UI language. To properly indicate that the
pages actually depend on that information, we add the correct Vary
header.

Fixes #106.
2022-01-22 15:19:29 +01:00
Jonas Schäfer
a1ecb4ce80 Port to WTForms 3.x
Fixes #103.
2022-01-22 15:17:48 +01:00
Jonas Schäfer
b84b84b394 Add check for a missing make extract_translations
Forgetting to run that causes weblate (or other translation tools) to
show outdated strings and not import new strings, which is bad for the
collaboration.

Fixes #118.
2022-01-22 14:57:59 +01:00
misiek
4f7a4fb5d4 Translated using Weblate (Polish)
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2022-01-21 15:00:36 +00:00
Jonas Schäfer
6d50b1c2c7 Do not show dependency versions even to admins by default
Dependency versions are generally not useful, unless you are developing
or otherwise outside of a normal release situation: If you are on a
normal release, we can figure out the dep versions by looking at the
docker image.

To reduce the amount of information displayed and the amount of
information which needs to be conveyed in case of problems, we only show
the web portal and prosody versions to admins, unless debug mode is
enabled.

The behaviour that versions are only shown to logged in admins (unless
debug mode is enabled) remains unchanged.

Fixes #115.
2022-01-20 18:11:47 +01:00
uira
34a23f8505 Translated using Weblate (Indonesian)
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/id/
2022-01-18 14:59:01 +00:00
Jonas Schäfer
ebcb083b6a Translated using Weblate (German)
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/de/
2022-01-18 14:59:01 +00:00
Kim Alvefur
2f0b38b149 Translated using Weblate (Swedish)
Currently translated at 100.0% (321 of 321 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2022-01-18 09:21:29 +00:00
Weblate
6244ad5c8a Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/
2022-01-17 16:27:36 +00:00
Jonas Schäfer
07fa1f0abd Fix missing space in string 2022-01-17 17:27:26 +01:00
Weblate
3d22458f9b Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/
2022-01-17 16:26:16 +00:00
Jonas Schäfer
3b768fe220 Extract translations 2022-01-17 17:26:02 +01:00
Matthew Wild
19cf82e894 Merge pull request #111 from snikket-im/fix/byte-scaling
Fix corner cases of byte number formatting
2022-01-17 15:47:57 +00:00
Matthew Wild
fe0316708b Merge pull request #113 from snikket-im/feature/flake8-print
Include flake8-print plugin
2022-01-17 15:47:21 +00:00
Jonas Schäfer
81b0a58dc9 Merge pull request #107 from Zash/storagestats
Show how much storage space is used by shared files
2022-01-17 16:40:16 +01:00
Jonas Schäfer
08aea153f9 Merge pull request #110 from snikket-im/feature/account-export
Feature: account import/export
2022-01-17 16:38:04 +01:00
Jonas Schäfer
958b3365f7 Remove strange greeting copied over from user_home 2022-01-17 16:34:30 +01:00
Matthew Wild
05caf38d37 Use PUT method instead of POST, as expected by API 2022-01-17 16:33:46 +01:00
Matthew Wild
390ecded42 Include PEP data in export/import 2022-01-17 16:33:29 +01:00
Matthew Wild
f6395d4d9c Complete the implementation of data import 2022-01-17 16:33:00 +01:00
Matthew Wild
32179c72cd Add account data import UI on registration success page 2022-01-17 16:24:00 +01:00
Matthew Wild
3cb8185b1a prosodyclient: Add API to import XEP-0227 account data 2022-01-17 16:23:58 +01:00
Matthew Wild
481379d03f Switch to HTTP 204 to indicate no data to export
This is more robust, as it indicates the request was successfully
authenticated and processed, but that there is no data to export. This is
different from the URL not existing (which would also happen if the module was
unavailable, which should be a notable error instead).
2022-01-17 16:23:57 +01:00
Matthew Wild
275b302531 Add UI for exporting user account data 2022-01-17 16:23:56 +01:00
Matthew Wild
e18f727db0 prosodyclient: Add support for exporting a user's account data 2022-01-17 16:23:55 +01:00
Matthew Wild
f7429413cd Add more icons to the repertoire 2022-01-17 16:23:35 +01:00
Jonas Schäfer
d5a46b69a6 Include flake8-print plugin
This alerts us of stray print statements, which should never occur
because this is a non-TUI application.
2022-01-15 17:07:16 +01:00
Jonas Schäfer
51f2ebbd13 Handle the correct exception when formatting extremely high amounts of bytes
Found in production. Yes really. Due to some borked LXC integration, my
snikket host reports

```
MemTotal:       9007199254740991 kB
MemFree:        9007199254690591 kB
MemAvailable:   9007199254690591 kB
```

That is more than 1024 TiB, so it tries to go further up in the scale,
which then causes a Guru Meditation because of the uncaught IndexError.
2022-01-10 17:32:11 +01:00
Jonas Schäfer
b4e6ee8943 Fix formatting of zero bytes
Previously, that would raise a ValueError (math domain error), because
log(0) is undefined.
2022-01-10 17:31:50 +01:00
Jonas Schäfer
52d8047546 Correctly detect presence of storage metric
If there have been no uploads yet, the metric will be zero, so the if
condition would fail the test, so it would render as "unknown".
2022-01-10 16:56:31 +01:00
Jonas Schäfer
aed9ad1cde Merge pull request #93 from Zash/debianbullseye
Dockerfile: Switch base image to Debian 11
2022-01-10 16:40:28 +01:00
Kim Alvefur
b545c137b1 Dockerfile: Switch base image to Debian 11 2022-01-10 16:27:38 +01:00
Matthew Wild
47642dc384 Merge pull request #108 from snikket-im/feature/multi-stage-dockerfile
Dockerfile: Split build into multiple stages
2022-01-10 14:21:57 +00:00
Jonas Schäfer
5d7183a0b8 Reinstate cache deletion in multi-stage build
Previously, the multi-stage build increased the image size by about 30
MiB (163MiB -> 191MiB). Dropping the caches reduces the image size down
to 159MiB, leading to a net improvement of 4 MiB.
2022-01-08 13:29:28 +01:00
Matthew Wild
c1cf6ab1e5 Dockerfile: Split build into multiple stages
Currently the Dockerfile has a single RUN directive with all the needed
commands in it. This optimizes for image size by not creating too many
"layers" (which are only additive). However it means the result that gets
cached can basically never be reused, because any change to the source code
will need to execute the whole RUN block again.

This commit switches to a docker "multi-stage" build, where we have a build
image that is separate from the final one that gets published. The build
image can be cached locally, and size is no longer a significant concern.

This approach allows the single RUN command to be split up into multiple RUN
commands that only execute when strictly needed (i.e. when their result
is not cached locally).

This drastically improves the build time when rebuilding the image after
a simple code change, because the build image doesn't have to install all
the apt packages, for example. This leads to a nicer developer experience
when using docker locally for development and testing.
2022-01-08 13:17:52 +01:00
Jonas Schäfer
aee53a2e1a Merge pull request #109 from snikket-im/feature/fix-mypy
Fix mypy false positives
2022-01-08 13:14:28 +01:00
Jonas Schäfer
3a81a0140b Revert "Fix spurious mypy error"
This reverts commit 28ff19c19c.
2022-01-08 13:12:30 +01:00
Jonas Schäfer
5b4d4ddd36 Fix some mypy regression 2022-01-08 13:12:30 +01:00
Jonas Schäfer
28ff19c19c Fix spurious mypy error
For whatever reason, it thinks that babel has no __version__ field, but
it in fact does.
2022-01-08 12:52:31 +01:00
uira
8e3837f704 Translated using Weblate (Indonesian)
Currently translated at 100.0% (303 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/id/
2021-12-12 07:00:42 +00:00
Kim Alvefur
4af78f635e Show how much storage space is used by shared files
Requires at least https://hg.prosody.im/prosody-modules/rev/883ad8b0a7c0
2021-11-18 19:50:47 +01:00
Matthew Wild
98e7de3166 Merge pull request #104 from snikket-im/feature/enable-restricted-users
admin: Show restricted user role in the UI
2021-11-09 17:03:08 +00:00
Matthew Wild
61c71b2145 admin: Inline restricted user role name
It was a variable only for the benefit of translators while disabled.
2021-11-09 17:00:59 +00:00
Matthew Wild
6b35e9a259 admin: Show restricted user role in the UI 2021-11-09 16:40:50 +00:00
Matthew Wild
58c2112fec Merge pull request #102 from snikket-im/feature/pin-wtforms
Pin wtforms to 2.x
2021-11-09 10:48:18 +00:00
Jonas Schäfer
c856afee82 Pin wtforms to 2.x
wtforms 3.0 is incompatible with our code. A separate issue will be
filed to address the incompatibilities, but this should be enough to get
working images out of it.

With 3.x, we're seeing:

```
  File "/home/horazont/Projects/python/snikket-web-portal/snikket_web/main.py", line 35, in LoginForm
    address = wtforms.TextField(
AttributeError: module 'wtforms' has no attribute 'TextField'
```

and the portal fails to start.
2021-11-08 18:07:34 +01:00
Matthew Wild
c8356a8e9e Merge pull request #101 from snikket-im/feature/https-qr
Force invite QR code to HTTPS
2021-10-15 14:32:57 +01:00
Jonas Schäfer
0eb464f428 Force invite QR code to HTTPS
We could also do a thing with ProxyFix, but honestly, this should always
be HTTPS.
2021-10-15 15:21:22 +02:00
misiek
2a6ef3c8f1 Translated using Weblate (Polish)
Currently translated at 100.0% (303 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-09-03 15:00:35 +00:00
Matthew Wild
b5d148458a Merge pull request #98 from snikket-im/feature/apple
🎉 Finally set the URL for the App Store
2021-09-02 14:42:02 +01:00
Jonas Schäfer
261758b07a 🎉 Finally set the URL for the App Store
See-Also: https://snikket.org/blog/snikket-ios-public-release/
2021-08-31 17:03:21 +02:00
Jonas Schäfer
ff99c9488a Merge pull request #96 from Zash/invite_success_link
Link to main page after successful registration
2021-08-30 15:47:41 +02:00
Kim Alvefur
fe78631039 Link to main page after successful registration
Someone who registers via the web might also be interested in the web
portal.

Thanks to Jonas and Matthew for feedback on draft commit and help with
whatever this template syntax is.

Also no thanks to git for not actually having draft commits as a concept.
Mercurial is so much nicer to work with.
2021-08-29 15:10:02 +02:00
Kim Alvefur
12ddd288bf Translated using Weblate (Swedish)
Currently translated at 100.0% (303 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2021-08-01 14:00:44 +00:00
misiek
633fb0d084 Translated using Weblate (Polish)
Currently translated at 97.0% (294 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-08-01 14:00:44 +00:00
uira
f9690063bc Translated using Weblate (Indonesian)
Currently translated at 100.0% (303 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/id/
2021-06-21 23:01:04 +00:00
Jonas Schäfer
65ed50acd3 Merge pull request #90 from snikket-im/hotfix/f-droid-button
Fix F-Droid installation button
2021-06-21 20:23:02 +02:00
Jonas Schäfer
aa04320d70 Fix F-Droid installation button
The button was broken because it was classified as popover, which
means that the JavaScript code will mess with it. In reality,
*that* button was supposed to point at the actual market:// URL.

So we just remove the class and associated data here to fix that.

Fixes #89.
2021-06-20 14:14:30 +02:00
Link Mauve
818d50a1bb Translated using Weblate (French)
Currently translated at 97.6% (296 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/fr/
2021-06-19 15:01:05 +00:00
Jonas Schäfer
c7ba7985ea Translated using Weblate (English (United Kingdom))
Currently translated at 40.5% (123 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/en_GB/
2021-06-19 15:01:05 +00:00
Jonas Schäfer
223d127364 Translated using Weblate (English)
Currently translated at 70.2% (213 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/en/
2021-06-19 15:01:04 +00:00
Jonas Schäfer
3a2c4543c4 Translated using Weblate (German)
Currently translated at 100.0% (303 of 303 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/de/
2021-06-19 15:01:04 +00:00
Weblate
c307f057b9 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/
2021-06-18 14:20:21 +00:00
Jonas Schäfer
243d5ba236 Merge pull request #86 from snikket-im/feature/show-deleted-circle-members
admin: Show deleted users in circle members
2021-06-18 16:20:18 +02:00
Jonas Schäfer
3d62efccfc admin: Show deleted users in circle members
This helps with removing those users from circles, to avoid them
popping up in peoples roster again.

Even though removal from a circle also only partially works
(roster entries are for instance not cleared), this helps with
ghost users reappearing all the time.
2021-06-18 16:18:22 +02:00
Jonas Schäfer
9d26e39025 Merge pull request #87 from snikket-im/feature/mypy-ci-fix
Install build requirements for mypy CI check
2021-06-18 16:18:12 +02:00
Jonas Schäfer
874f0447ba Install build requirements for mypy CI check
Otherwise, the toml type hints are missing which mypy does not
like.
2021-06-18 16:14:45 +02:00
Jonas Schäfer
0f2127a672 Bring happiness to mypy 2021-06-18 16:11:22 +02:00
Weblate
20d84e7dd1 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/
2021-06-18 14:05:55 +00:00
Jonas Schäfer
a02e66023c Fix missing extract_translations run 2021-06-18 16:05:38 +02:00
Jonas Schäfer
e7db9cc772 Fix untranslated table header in admin_edit_circle.html 2021-06-17 17:02:26 +02:00
Jonas Schäfer
e91fb45374 Merge pull request #85 from snikket-im/auto-versioning
Automatically determine version from build info or git
2021-05-31 17:51:59 +02:00
Matthew Wild
531565d55c Automatically determine version from build info or git 2021-05-31 11:20:39 +01:00
Kim Alvefur
c6307619f9 Translated using Weblate (Swedish)
Currently translated at 100.0% (302 of 302 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/sv/
2021-05-28 21:00:56 +00:00
Weblate
da2668cbbc Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/
2021-05-27 18:31:31 +00:00
Link Mauve
765e3890b4 Translated using Weblate (French)
Currently translated at 97.4% (272 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/fr/
2021-05-27 18:31:30 +00:00
Jonas Schäfer
b40a625283 admin: allow disabling display of metrics
This is useful in situations where the admins of the Snikket
server (i.e. those who care for the docker containers) are not the
same people as the people who are admins of the Snikket service
(i.e. those who care for the users).
2021-05-27 17:59:40 +02:00
Jonas Schäfer
8a293985ca Implement system status panel
This offers system metrics and a way to send a broadcast
message to all online or registered users.

Requires prosody-modules cade5dac1003.
2021-05-27 17:21:58 +02:00
Jonas Schäfer
13b2a76c3d Fix mypy errors introduced in b007afc901 2021-05-27 16:33:46 +02:00
Jonas Schäfer
28e01c336d Do not install quart 0.15
As we saw in b007afc901, we cannot
use that version right now.
2021-05-25 18:56:15 +02:00
Jonas Schäfer
5fb0b91178 Bumping version number to 0.2.1 2021-05-22 11:11:50 +02:00
Jonas Schäfer
b007afc901 Revert "Upgrade to quart 0.15"
This reverts commit 486596f89f.
It was discovered that multipart/form-data forms do not work
correctly with Quart 0.15. The upgrade to Quart 0.15 was rushed
and not tested correctly, which I apologize for.

See-Also: https://github.com/pgjones/quart/issues/126
2021-05-22 11:11:16 +02:00
Matthew Wild
7f02746f63 admin: Re-disable 'limited' role (accidentally uncommented in c58ce8450) 2021-05-19 21:59:16 +01:00
Roberto Resoli
f2788aeb36 Translated using Weblate (Italian)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/it/
2021-05-19 15:12:20 +00:00
misiek
536a05b0eb Translated using Weblate (Polish)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-05-19 15:12:20 +00:00
uira
e0226d47e3 Translated using Weblate (Indonesian)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/id/
2021-05-19 15:12:20 +00:00
misiek
0fe10a44ce Translated using Weblate (Polish)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-05-18 13:29:00 +00:00
Jonas Schäfer
e892d81815 Translated using Weblate (German)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/de/
2021-05-18 13:29:00 +00:00
Jonas Schäfer
c58ce8450f Fix type annotations after bumping dependencies 2021-05-18 14:33:06 +02:00
Jonas Schäfer
03573d1f05 Explicitly pass mod_rest JSON as JSON
Otherwise, it can get passed as x-www-form-urlencoded, which
Prosody understandably does not quite like.
2021-05-18 12:35:46 +02:00
Jonas Schäfer
486596f89f Upgrade to quart 0.15 2021-05-18 12:35:31 +02:00
Jonas Schäfer
425b4d4295 Fix dysfunctional password reset button 2021-05-18 12:20:45 +02:00
misiek
87de808046 Translated using Weblate (Polish)
Currently translated at 97.8% (273 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-04-23 20:00:57 +00:00
misiek
05455ac743 Translated using Weblate (Polish)
Currently translated at 96.0% (268 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-04-05 19:01:04 +00:00
Daniel Holmgaard
1e926714cb Translated using Weblate (Danish)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/da/
2021-04-02 19:01:03 +00:00
Tilman Jiménez
e1602f3140 Translated using Weblate (Spanish (Mexico))
Currently translated at 49.1% (137 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/es_MX/
2021-03-31 13:00:53 +00:00
uira
2e89973263 Translated using Weblate (Indonesian)
Currently translated at 99.2% (277 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/id/
2021-03-29 08:00:48 +00:00
Kim Alvefur
a6f1361ddd Translated using Weblate (Swedish)
Currently translated at 100.0% (279 of 279 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/sv/
2021-03-27 00:01:12 +00:00
Weblate
552a3bbd41 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/
2021-03-25 16:35:52 +00:00
misiek
3f2de1e5bf Translated using Weblate (Polish)
Currently translated at 97.6% (248 of 254 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/pl/
2021-03-25 16:35:49 +00:00
Jonas Schäfer
059a10f475 Merge pull request #76 from snikket-im/feature/roles
"Edit user" flow, role management
2021-03-25 17:35:42 +01:00
Jonas Schäfer
a48abacf1d Disable restricted role for now
It is not implemented in snikket-server yet, so we don’t want to
put anything misleading out there.
2021-03-25 17:32:03 +01:00
Jonas Schäfer
ea7ed7c030 Add support for roles
Requires patches to prosody trunk which have been submitted
already (2021-03-22) which introduce the set_roles function on
usermanager.

Fixes #42.
2021-03-25 17:31:56 +01:00
Jonas Schäfer
cca899bd8c Create "Edit user" form
This aggregates the user actions behind a single "edit" button on
the list view, making it less crammed. It also offers the
functionality of actually editing the user, mind.

Also in preparation for #42.

Requires https://hg.prosody.im/prosody-modules/rev/5bc706c2db8f.
2021-03-25 17:31:49 +01:00
Jonas Schäfer
359e6b4ce2 Use tertiary style for "back" buttons
This allows us to have two levels of emphasis for the actual
form buttons and is also in line with the global "Log out"
navigational button.
2021-03-25 17:31:43 +01:00
Jonas Schäfer
6650dd2046 Capitalize App Store in the invite for consistency 2021-03-25 17:28:36 +01:00
Jonas Schäfer
97b4a7be0f Merge pull request #77 from Zash/mod_rest-version-change
Update for switch to datamapper in mod_rest
2021-03-24 09:10:22 +01:00
Kim Alvefur
329916e200 Update for switch to datamapper in mod_rest
mod_rest after the switch to the new util.datamapper in
https://hg.prosody.im/prosody-modules/rev/073f5397c1d2 does not accept
boolean True as value for the xep-0092 'version' field. An empty object
is equivalent and compatible with both previous and future versions.
2021-03-23 21:38:34 +01:00
Kim Alvefur
3571b8909b Translated using Weblate (Swedish)
Currently translated at 100.0% (254 of 254 strings)

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/sv/
2021-03-23 15:00:36 +00:00
Jonas Schäfer
c6c01b82f5 Merge pull request #74 from snikket-im/feature/invite-from-users-list
Render invite form below user list
2021-03-22 15:30:56 +01:00
Weblate
c4b575f091 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Snikket/Web Portal
Translate-URL: https://i18n.sotecware.net/projects/snikket/web-portal/
2021-03-22 14:09:01 +00:00
Jonas Schäfer
fdb55568ec Change problematic "Back" buttons
Fixes #39.
2021-03-22 15:08:33 +01:00
62 changed files with 9260 additions and 2470 deletions

View File

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

View File

@@ -1,7 +1,12 @@
FROM debian:buster-slim
FROM debian:bullseye-slim AS build
ARG BUILD_SERIES=dev
ARG BUILD_ID=0
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;
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
COPY build-requirements.txt /opt/snikket-web-portal/build-requirements.txt
@@ -12,30 +17,43 @@ COPY babel.cfg /opt/snikket-web-portal/babel.cfg
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 \
; \
pip3 install -r requirements.txt; \
pip3 install -r build-requirements.txt; \
make; \
pip3 uninstall -yr build-requirements.txt; \
apt-get remove -y build-essential make libpython3-dev; \
apt-get autoremove -y; \
pip3 install hypercorn; \
rm -rf /root/.cache; \
apt-get clean ; rm -rf /var/lib/apt/lists
make;
FROM debian:bullseye-slim
ARG BUILD_SERIES=dev
ARG BUILD_ID=0
COPY docker/env.py /etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/
COPY requirements.txt /opt/snikket-web-portal/requirements.txt
WORKDIR /opt/snikket-web-portal
RUN set -eu; \
export DEBIAN_FRONTEND=noninteractive ; \
apt-get update ; \
apt-get install -y --no-install-recommends \
python3 python3-pip python3-setuptools python3-wheel build-essential libpython3-dev netcat; \
pip3 install -r requirements.txt; \
apt-get remove -y --autoremove build-essential libpython3-dev; \
apt-get clean ; rm -rf /var/lib/apt/lists; \
pip3 install hypercorn; \
rm -rf /root/.cache;
HEALTHCHECK CMD nc -zv ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE:-127.0.0.1} ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT:-5765}
COPY --from=build /opt/snikket-web-portal/snikket_web/ /opt/snikket-web-portal/snikket_web
COPY babel.cfg /opt/snikket-web-portal/babel.cfg
RUN echo "$BUILD_SERIES $BUILD_ID" > /opt/snikket-web-portal/.app_version
ADD docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

View File

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

View File

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

View File

@@ -5,4 +5,4 @@ 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()'
exec hypercorn -b "${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE}:${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT}" --access-logfile=- --log-file=- 'snikket_web:create_app()'

View File

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

View File

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

View File

@@ -1,5 +1,15 @@
version_info = (0, 1, 2, "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)"

View File

@@ -1,14 +1,17 @@
import json
import resource
import time
import typing
from datetime import datetime
import aiohttp
import werkzeug.exceptions
import quart.flask_patch
import wtforms
import wtforms.fields.html5
from quart import (
Blueprint,
@@ -18,11 +21,12 @@ from quart import (
request,
abort,
flash,
current_app,
)
from flask_babel import lazy_gettext as _l, _
from . import prosodyclient
from . import prosodyclient, _version
from .infra import client, circle_name, BaseForm
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -31,11 +35,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(BaseForm):
action_create = wtforms.StringField()
action_revoke = wtforms.StringField()
@@ -57,6 +64,82 @@ async def users() -> str:
)
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[werkzeug.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")
@@ -65,7 +148,7 @@ class DeleteUserForm(BaseForm):
@bp.route("/user/<localpart>/delete", methods=["GET", "POST"])
@client.require_admin_session()
async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
async def delete_user(localpart: str) -> typing.Union[str, werkzeug.Response]:
target_user_info = await client.get_user_by_localpart(localpart)
form = DeleteUserForm()
if form.validate_on_submit():
@@ -100,36 +183,38 @@ 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, werkzeug.Response]:
invite_info = await client.get_invite_by_id(
id_,
)
if invite_info.jid is None:
await flash(
_("Password reset link created"),
"success",
)
elif form.action_revoke.data:
await client.delete_invite(form.action_revoke.data)
await flash(
_("Password reset link deleted"),
"success",
_("Password reset link not found"),
"alert",
)
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,
)
@@ -194,7 +279,7 @@ class InvitePost(BaseForm):
@bp.route("/invitations", methods=["GET", "POST"])
@client.require_admin_session()
async def invitations() -> typing.Union[str, quart.Response]:
async def invitations() -> typing.Union[str, werkzeug.Response]:
invites = sorted(
(
invite
@@ -240,7 +325,7 @@ class InviteForm(BaseForm):
@bp.route("/invitation/-/new", methods=["POST"])
@client.require_admin_session()
async def create_invite() -> typing.Union[str, quart.Response]:
async def create_invite() -> typing.Union[str, werkzeug.Response]:
form = InvitePost()
circles = await client.list_groups()
form.circles.choices = [
@@ -268,7 +353,7 @@ 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]:
async def edit_invite(id_: str) -> typing.Union[str, werkzeug.Response]:
try:
invite_info = await client.get_invite_by_id(id_)
except aiohttp.ClientResponseError as exc:
@@ -334,7 +419,7 @@ async def circles() -> str:
@bp.route("/circle/-/new", methods=["POST"])
@client.require_admin_session()
async def create_circle() -> typing.Union[str, quart.Response]:
async def create_circle() -> typing.Union[str, werkzeug.Response]:
create_form = CirclePost()
if create_form.validate_on_submit():
circle = await client.create_group(
@@ -380,7 +465,7 @@ class EditCircleForm(BaseForm):
@bp.route("/circle/<id_>", methods=["GET", "POST"])
@client.require_admin_session()
async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
@@ -396,21 +481,21 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
return redirect(url_for(".circles"))
raise
users = sorted(
await client.list_users(),
key=lambda x: x.localpart
)
users = {
user.localpart: user
for user in await client.list_users()
}
circle_members = [
user for user in users
if user.localpart in circle.members
(localpart, users.get(localpart))
for localpart in sorted(circle.members)
]
form = EditCircleForm()
form.user_to_add.choices = [
(user.localpart, user.localpart)
for user in users
if user.localpart not in circle.members
]
form.user_to_add.choices = sorted(
(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]
invite_form = InvitePost()
@@ -458,8 +543,6 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
)
return redirect(url_for(".edit_circle", id_=id_))
else:
print(form.errors)
return await render_template(
"admin_edit_circle.html",
@@ -468,3 +551,153 @@ async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
circle_members=circle_members,
invite_form=invite_form,
)
_CPU_EPOCH = time.process_time()
_MONOTONIC_EPOCH = time.monotonic()
def get_system_stats() -> typing.MutableMapping[
str,
typing.Optional[typing.Union[int, float]]]:
pagesize = resource.getpagesize()
my_rss: typing.Optional[int] = None
try:
with open("/proc/self/statm") as f:
stats = f.read().split()
my_rss = int(stats[1]) * pagesize
except (ValueError, IndexError, TypeError, OSError):
pass
my_cpu = (
(time.process_time() - _CPU_EPOCH) /
(time.monotonic() - _MONOTONIC_EPOCH)
)
mem_total, mem_available = None, None
load5: typing.Optional[float] = None
try:
with open("/proc/loadavg") as f:
stats = f.read().split()
load5 = float(stats[1])
except (ValueError, IndexError, TypeError, OSError):
pass
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal"):
mem_total = int(line.split()[1]) * 1024
elif line.startswith("MemAvailable"):
mem_available = int(line.split()[1]) * 1024
if mem_total is not None and mem_available is not None:
break
except (ValueError, TypeError, IndexError, OSError):
pass
return {
"portal_rss": my_rss,
"portal_cpu": my_cpu,
"load5": load5,
"mem_total": mem_total,
"mem_available": mem_available,
}
class AnnouncementForm(BaseForm):
text = wtforms.StringField(
_("Message contents"),
widget=wtforms.widgets.TextArea(),
validators=[wtforms.validators.DataRequired()],
)
online_only = wtforms.BooleanField(
_("Only send to online users"),
)
action_post_all = wtforms.SubmitField(
_("Post to all users"),
)
action_send_preview = wtforms.SubmitField(
_("Send preview to yourself"),
)
@bp.route("/system/", methods=["GET", "POST"])
@client.require_admin_session()
async def system() -> typing.Union[str, werkzeug.Response]:
form = AnnouncementForm()
if form.validate_on_submit():
recipients = "self"
if form.action_post_all.data:
if form.online_only.data:
recipients = "online"
else:
recipients = "all"
await client.post_announcement(
form.text.data,
recipients=recipients,
)
await flash(
_("Announcement sent!"),
"success",
)
if recipients != "self":
# redirect only if not previewing
return redirect(url_for(".system"))
version = None
now = None
show_metrics = current_app.config["SHOW_METRICS"]
if show_metrics:
version = await client.get_server_version()
now = time.time()
try:
prosody_metrics = await client.get_system_metrics()
except werkzeug.exceptions.NotFound:
# server does not offer the endpoint for whatever reason -- ignore
prosody_metrics = {}
metrics = get_system_stats()
try:
prosody_cpu_metrics = prosody_metrics["cpu"]
except KeyError:
pass
else:
metrics["prosody_cpu"] = (prosody_cpu_metrics["value"] /
(now - prosody_cpu_metrics["since"]))
try:
metrics["prosody_rss"] = prosody_metrics["memory"]
except KeyError:
pass
try:
metrics["prosody_devices"] = prosody_metrics["c2s"]
except KeyError:
pass
try:
metrics["prosody_uploads"] = prosody_metrics["uploads"]
except KeyError:
pass
for k in list(metrics.keys()):
if metrics[k] is None:
# so that defaulting in jinja works
del metrics[k]
else:
metrics = {}
return await render_template(
"admin_system.html",
metrics=metrics,
version=_version.version,
prosody_version=version,
form=form,
show_metrics=show_metrics,
)

View File

@@ -1,5 +1,6 @@
import base64
import itertools
import math
import secrets
import typing
@@ -7,6 +8,7 @@ import quart.flask_patch # noqa:F401
from quart import (
current_app,
request,
g,
)
import flask_babel
@@ -22,11 +24,21 @@ client.default_login_redirect = "main.login"
babel = flask_babel.Babel()
BYTE_UNIT_SCALE_MAP = [
"B",
"kiB",
"MiB",
"GiB",
"TiB",
]
@babel.localeselector # type:ignore
def selected_locale() -> str:
g.language_header_accessed = True
selected = request.accept_languages.best_match(
current_app.config['LANGUAGES']
)
) or current_app.config['LANGUAGES'][0]
return selected
@@ -42,14 +54,39 @@ def circle_name(c: typing.Any) -> str:
return c.name
def format_bytes(n: float) -> str:
try:
scale = max(math.floor(math.log(n, 1024)), 0)
except ValueError:
scale = 0
try:
unit = BYTE_UNIT_SCALE_MAP[scale]
factor = 1024**scale
except IndexError:
unit = "TiB"
factor = 1024**4
if factor > 1:
return "{:.1f}{}".format(n / factor, unit)
return "{}{}".format(n, unit)
def add_vary_language_header(resp: quart.Response) -> quart.Response:
if getattr(g, "language_header_accessed", False):
resp.vary.add("Accept-Language")
return resp
def init_templating(app: quart.Quart) -> None:
app.template_filter("repr")(repr)
app.template_filter("format_datetime")(flask_babel.format_datetime)
app.template_filter("format_date")(flask_babel.format_date)
app.template_filter("format_time")(flask_babel.format_time)
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
app.template_filter("format_percent")(flask_babel.format_percent)
app.template_filter("format_bytes")(format_bytes)
app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name)
app.after_request(add_vary_language_header)
def generate_error_id() -> str:

View File

@@ -10,13 +10,16 @@ from quart import (
current_app,
render_template,
redirect,
request,
url_for,
session as http_session,
)
import werkzeug
import wtforms
from flask_babel import lazy_gettext as _l
from flask_babel import lazy_gettext as _l, gettext
from .infra import client, selected_locale, BaseForm
@@ -26,6 +29,11 @@ bp = Blueprint("invite", __name__)
INVITE_SESSION_JID = "invite-session-jid"
MAX_IMPORT_DATA_SIZE = 5*1024*1024 # 5MB
SUPPORTED_IMPORT_TYPES = ["application/xml", "text/xml"]
EIMPORTTOOBIG = _l("The account data you tried to import is too large to"
" upload. Please contact your Snikket operator.")
# https://play.google.com/store/apps/details?id=org.snikket.android&referrer={uri|urlescape}&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1
@@ -40,14 +48,14 @@ def apple_store_badge() -> str:
@bp.context_processor
def context() -> typing.Mapping[str, typing.Any]:
def context() -> typing.Dict[str, typing.Any]:
return {
"apple_store_badge": apple_store_badge,
}
@bp.route("/<id_>")
async def view_old(id_: str) -> quart.Response:
async def view_old(id_: str) -> werkzeug.Response:
return redirect(url_for(".view", id_=id_))
@@ -96,7 +104,7 @@ async def view(id_: str) -> typing.Union[quart.Response,
return quart.Response(
body,
headers={
"Link": "<{}> rel=\"alternate\"".format(invite.xmpp_uri),
"Link": "<{}>; rel=\"alternate\"".format(invite.xmpp_uri),
}
)
@@ -125,7 +133,7 @@ class RegisterForm(BaseForm):
@bp.route("/<id_>/register", methods=["GET", "POST"])
async def register(id_: str) -> typing.Union[str, quart.Response]:
async def register(id_: str) -> typing.Union[str, werkzeug.Response]:
try:
invite = await client.get_public_invite_by_id(id_)
except aiohttp.ClientResponseError as exc:
@@ -163,6 +171,7 @@ async def register(id_: str) -> typing.Union[str, quart.Response]:
raise
else:
http_session[INVITE_SESSION_JID] = jid
await client.login(jid, form.password.data)
return redirect(url_for(".success"))
return await render_template(
@@ -192,7 +201,7 @@ class ResetForm(BaseForm):
@bp.route("/<id_>/reset", methods=["GET", "POST"])
async def reset(id_: str) -> typing.Union[str, quart.Response]:
async def reset(id_: str) -> typing.Union[str, werkzeug.Response]:
try:
invite = await client.get_public_invite_by_id(id_)
except aiohttp.ClientResponseError as exc:
@@ -232,11 +241,55 @@ async def reset(id_: str) -> typing.Union[str, quart.Response]:
)
class DataImportForm(BaseForm):
account_data_file = wtforms.FileField(
_l("Account data file")
)
action_import = wtforms.SubmitField(
_l("Import data")
)
@bp.route("/success", methods=["GET", "POST"])
@client.require_session()
async def success() -> str:
form = DataImportForm()
if form.validate_on_submit():
ok = True
file_info = (await request.files).get(form.account_data_file.name)
if file_info is not None:
mimetype = file_info.mimetype
data = file_info.stream.read()
if len(data) > MAX_IMPORT_DATA_SIZE:
form.account_data_file.errors.append(EIMPORTTOOBIG)
ok = False
elif mimetype not in SUPPORTED_IMPORT_TYPES:
form.account_data_file.errors.append(
# not breaking the line here to avoid extract
# translations failing (defensive)
gettext("The account data you tried to import is in an unknown format. Please upload an XML file in XEP-0227 format (provided format: %(mimetype)s).", mimetype=mimetype), # noqa:E501
)
ok = False
elif len(data) > 0:
await client.import_account_data(data)
if ok:
# Re-render success page, this time with no import option
return await render_template(
"invite_success.html",
jid=http_session.get(INVITE_SESSION_JID, ""),
migration_success=True,
)
return await render_template(
"invite_success.html",
jid=http_session.get(INVITE_SESSION_JID, ""),
migration_success=False,
form=form,
max_import_size=MAX_IMPORT_DATA_SIZE,
import_too_big_warning_header=_l("Error"),
import_too_big_warning=EIMPORTTOOBIG,
)
@@ -249,5 +302,5 @@ async def reset_success() -> str:
@bp.route("/-")
async def index() -> quart.Response:
async def index() -> werkzeug.Response:
return redirect(url_for("index"))

View File

@@ -18,6 +18,8 @@ from quart import (
flash,
)
import werkzeug.exceptions
import babel
import wtforms
@@ -32,7 +34,7 @@ bp = quart.Blueprint("main", __name__)
class LoginForm(BaseForm):
address = wtforms.TextField(
address = wtforms.StringField(
_l("Address"),
validators=[wtforms.validators.InputRequired()],
)
@@ -48,7 +50,7 @@ class LoginForm(BaseForm):
@bp.route("/-")
async def index() -> quart.Response:
async def index() -> werkzeug.Response:
return redirect(url_for("index"))
@@ -56,7 +58,7 @@ ERR_CREDENTIALS_INVALID = _l("Invalid username or password.")
@bp.route("/login", methods=["GET", "POST"])
async def login() -> typing.Union[str, quart.Response]:
async def login() -> typing.Union[str, werkzeug.Response]:
if client.has_session and (await client.test_session()):
return redirect(url_for('user.index'))
@@ -76,7 +78,7 @@ async def login() -> typing.Union[str, quart.Response]:
password = form.password.data
try:
await client.login(jid, password)
except quart.exceptions.Unauthorized:
except werkzeug.exceptions.Unauthorized:
form.password.errors.append(ERR_CREDENTIALS_INVALID)
else:
await flash(
@@ -91,24 +93,30 @@ async def login() -> typing.Union[str, quart.Response]:
@bp.route("/meta/about.html")
async def about() -> str:
version = None
core_versions = {}
extra_versions = {}
if current_app.debug or client.is_admin_session:
version = _version.version
extra_versions["Quart"] = quart.__version__
try:
core_versions["Prosody"] = await client.get_server_version()
except werkzeug.exceptions.Unauthorized:
core_versions["Prosody"] = "unknown"
if current_app.debug:
extra_versions["aiohttp"] = aiohttp.__version__
extra_versions["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:
except werkzeug.exceptions.Unauthorized:
extra_versions["Prosody"] = "unknown"
return await render_template(
"about.html",
version=version,
extra_versions=extra_versions,
core_versions=core_versions,
)
@@ -123,6 +131,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:

View File

@@ -19,7 +19,9 @@ from quart import (
current_app, _app_ctx_stack, session as http_session, abort, redirect,
url_for,
)
import quart.exceptions
import quart
import werkzeug.exceptions
from . import xmpputil
from .xmpputil import split_jid
@@ -44,6 +46,15 @@ class AdminUserInfo:
display_name: typing.Optional[str]
email: typing.Optional[str]
phone: typing.Optional[str]
roles: typing.Optional[typing.List[str]]
@property
def has_admin_role(self) -> bool:
return bool(self.roles and "prosody:admin" in self.roles)
@property
def has_restricted_role(self) -> bool:
return bool(self.roles and "prosody:restricted" in self.roles)
@classmethod
def from_api_response(
@@ -55,6 +66,7 @@ class AdminUserInfo:
display_name=data.get("display_name") or None,
email=data.get("email") or None,
phone=data.get("phone") or None,
roles=data.get("roles"),
)
@@ -286,6 +298,9 @@ class ProsodyClient:
def _public_v1_endpoint(self, subpath: str) -> str:
return "{}/register_api{}".format(self._endpoint_base, subpath)
def _xep227_endpoint(self, subpath: str) -> str:
return "{}/xep227{}".format(self._endpoint_base, subpath)
async def _oauth2_bearer_token(self,
session: aiohttp.ClientSession,
jid: str,
@@ -373,16 +388,16 @@ class ProsodyClient:
) -> typing.Callable[
[typing.Callable[..., typing.Awaitable[T]]],
typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]]:
typing.Union[T, quart.Response, werkzeug.Response]]]]:
def decorator(
f: typing.Callable[..., typing.Awaitable[T]],
) -> typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]:
typing.Union[T, quart.Response, werkzeug.Response]]]:
@functools.wraps(f)
async def wrapped(
*args: typing.Any,
**kwargs: typing.Any,
) -> typing.Union[T, quart.Response]:
) -> typing.Union[T, quart.Response, werkzeug.Response]:
if not self.has_session or not (await self.test_session()):
redirect_to_value = redirect_to
if redirect_to_value is not False:
@@ -402,17 +417,17 @@ class ProsodyClient:
) -> typing.Callable[
[typing.Callable[..., typing.Awaitable[T]]],
typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]]:
typing.Union[T, quart.Response, werkzeug.Response]]]]:
def decorator(
f: typing.Callable[..., typing.Awaitable[T]],
) -> typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]:
typing.Union[T, quart.Response, werkzeug.Response]]]:
@functools.wraps(f)
@self.require_session(redirect_to=redirect_to)
async def wrapped(
*args: typing.Any,
**kwargs: typing.Any,
) -> typing.Union[T, quart.Response]:
) -> typing.Union[T, quart.Response, werkzeug.Response]:
if not self.is_admin_session:
raise abort(403, "This is not for you.")
@@ -479,7 +494,7 @@ class ProsodyClient:
session=session,
)
avatar_hash = avatar_info["sha1"]
except quart.exceptions.HTTPException:
except werkzeug.exceptions.HTTPException:
avatar_hash = None
return {
@@ -500,7 +515,7 @@ 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
@@ -509,11 +524,11 @@ class ProsodyClient:
req = {
"kind": "iq",
"type": "get",
"version": True,
"version": {},
"to": domain,
}
async with session.post(self._rest_endpoint, data=req) as resp:
async with session.post(self._rest_endpoint, json=req) as resp:
if resp.status != 200:
return "unknwn"
try:
@@ -631,7 +646,7 @@ class ProsodyClient:
new_access_model,
)
))
except quart.exceptions.NotFound:
except werkzeug.exceptions.NotFound:
if ignore_not_found:
return
raise
@@ -761,7 +776,7 @@ class ProsodyClient:
session: aiohttp.ClientSession,
) -> str:
access_models = filter(
lambda x: not isinstance(x, quart.exceptions.NotFound),
lambda x: not isinstance(x, werkzeug.exceptions.NotFound),
await asyncio.gather(
self.get_avatar_access_model(session=session),
self.get_nickname_access_model(session=session),
@@ -858,6 +873,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,
@@ -1088,6 +1126,34 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def export_account_data(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Optional[str]:
async with session.get(
self._xep227_endpoint("/export?stores=roster,vcard,pep,pep_data"), # noqa:E501
) as resp:
self._raise_error_from_response(resp)
if resp.status == 204:
return None
return await resp.text()
@autosession
async def import_account_data(
self,
user_xml: str,
*,
session: aiohttp.ClientSession,
) -> bool:
async with session.put(
self._xep227_endpoint("/import?stores=roster,vcard,pep,pep_data"), # noqa:E501
data=user_xml,
) as resp:
self._raise_error_from_response(resp)
return True
@autosession
async def revoke_token(
self,
@@ -1142,3 +1208,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()

View File

@@ -354,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;

View File

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

View File

@@ -37,6 +37,16 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M10.79 16.29c.39.39 1.02.39 1.41 0l3.59-3.59c.39-.39.39-1.02 0-1.41L12.2 7.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L12.67 11H4c-.55 0-1 .45-1 1s.45 1 1 1h8.67l-1.88 1.88c-.39.39-.38 1.03 0 1.41zM19 3H5c-1.11 0-2 .9-2 2v3c0 .55.45 1 1 1s1-.45 1-1V6c0-.55.45-1 1-1h12c.55 0 1 .45 1 1v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1v3c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</symbol>
<!-- from: action/lock/materialiconsround/24px.svg -->
<symbol id="icon-lock" viewBox="0 0 24 24">
<g fill="none"><path d="M0 0h24v24H0V0z" /><path d="M0 0h24v24H0V0z" opacity=".87" /></g>
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z" />
</symbol>
<!-- from: communication/import_export/materialiconsround/24px.svg -->
<symbol id="icon-import_export" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M8.65 3.35L5.86 6.14c-.32.31-.1.85.35.85H8V13c0 .55.45 1 1 1s1-.45 1-1V6.99h1.79c.45 0 .67-.54.35-.85L9.35 3.35c-.19-.19-.51-.19-.7 0zM16 17.01V11c0-.55-.45-1-1-1s-1 .45-1 1v6.01h-1.79c-.45 0-.67.54-.35.85l2.79 2.78c.2.19.51.19.71 0l2.79-2.78c.32-.31.09-.85-.35-.85H16z" />
</symbol>
<!-- from: communication/qr_code/materialiconsround/24px.svg -->
<symbol id="icon-qrcode" viewBox="0 0 24 24">
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
@@ -47,6 +57,12 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
</symbol>
<!-- from: communication/rss_feed/materialiconsround/24px.svg -->
<symbol id="icon-broadcast" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<circle cx="6.18" cy="17.82" r="2.18" />
<path d="M5.59 10.23c-.84-.14-1.59.55-1.59 1.4 0 .71.53 1.28 1.23 1.4 2.92.51 5.22 2.82 5.74 5.74.12.7.69 1.23 1.4 1.23.85 0 1.54-.75 1.41-1.59-.68-4.2-3.99-7.51-8.19-8.18zm-.03-5.71C4.73 4.43 4 5.1 4 5.93c0 .73.55 1.33 1.27 1.4 6.01.6 10.79 5.38 11.39 11.39.07.73.67 1.28 1.4 1.28.84 0 1.5-.73 1.42-1.56-.73-7.34-6.57-13.19-13.92-13.92z" />
</symbol>
<!-- from: content/add_circle_outline/materialiconsround/24px.svg -->
<symbol id="icon-add" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
@@ -72,6 +88,26 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M21.94 11.23C21.57 8.76 19.32 7 16.82 7h-2.87c-.52 0-.95.43-.95.95s.43.95.95.95h2.9c1.6 0 3.04 1.14 3.22 2.73.17 1.43-.64 2.69-1.85 3.22l1.4 1.4c1.63-1.02 2.64-2.91 2.32-5.02zM4.12 3.56c-.39-.39-1.02-.39-1.41 0s-.39 1.02 0 1.41l2.4 2.4c-1.94.8-3.27 2.77-3.09 5.04C2.23 15.05 4.59 17 7.23 17h2.82c.52 0 .95-.43.95-.95s-.43-.95-.95-.95H7.16c-1.63 0-3.1-1.19-3.25-2.82-.15-1.72 1.11-3.17 2.75-3.35l2.1 2.1c-.43.09-.76.46-.76.92v.1c0 .52.43.95.95.95h1.78L13 15.27V17h1.73l3.3 3.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L4.12 3.56zM16 11.95c0-.52-.43-.95-.95-.95h-.66l1.49 1.49c.07-.13.12-.28.12-.44v-.1z" />
</symbol>
<!-- from: content/send/materialiconsround/24px.svg -->
<symbol id="icon-send" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3.4 20.4l17.45-7.48c.81-.35.81-1.49 0-1.84L3.4 3.6c-.66-.29-1.39.2-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
</symbol>
<!-- from: file/file_download/materialicons/24px.svg -->
<symbol id="icon-download" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
</symbol>
<!-- from: file/file_upload/materialicons/24px.svg -->
<symbol id="icon-upload" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z" />
</symbol>
<!-- from: file/folder/materialiconsround/24px.svg -->
<symbol id="icon-folder" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M10.59 4.59C10.21 4.21 9.7 4 9.17 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-1.41-1.41z" />
</symbol>
<!-- from: navigation/arrow_back/materialiconsround/24px.svg -->
<symbol id="icon-back" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
@@ -137,4 +173,9 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M17 7h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c1.65 0 3 1.35 3 3s-1.35 3-3 3h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-9 5c0 .55.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1H9c-.55 0-1 .45-1 1zm2 3H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h3c.55 0 1-.45 1-1s-.45-1-1-1H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h3c.55 0 1-.45 1-1s-.45-1-1-1z" />
</symbol>
<!-- from: content/insights/materialiconsround/24px.svg -->
<symbol id="icon-insights" viewBox="0 0 24 24">
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
<g><g><path d="M21,8c-1.45,0-2.26,1.44-1.93,2.51l-3.55,3.56c-0.3-0.09-0.74-0.09-1.04,0l-2.55-2.55C12.27,10.45,11.46,9,10,9 c-1.45,0-2.27,1.44-1.93,2.52l-4.56,4.55C2.44,15.74,1,16.55,1,18c0,1.1,0.9,2,2,2c1.45,0,2.26-1.44,1.93-2.51l4.55-4.56 c0.3,0.09,0.74,0.09,1.04,0l2.55,2.55C12.73,16.55,13.54,18,15,18c1.45,0,2.27-1.44,1.93-2.52l3.56-3.55 C21.56,12.26,23,11.45,23,10C23,8.9,22.1,8,21,8z" /><polygon points="15,9 15.94,6.93 18,6 15.94,5.07 15,3 14.08,5.07 12,6 14.08,6.93" /><polygon points="3.5,11 4,9 6,8.5 4,8 3.5,6 3,8 1,8.5 3,9" /></g></g>
</symbol>
</defs></svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -17,9 +17,12 @@
<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{% if version %} ({{ version }}){% endif %}
<pre>Domain: {{ config["SNIKKET_DOMAIN"] }}
Web Portal{% if version %} ({{ version }}){% endif %}
{%- if core_versions -%}
{% for name, version in core_versions.items() %}
{{ name }} ({{ version }}){% endfor %}
{%- endif -%}
{%- if extra_versions -%}
{% for name, version in extra_versions.items() %}
{{ name }} ({{ version }}){% endfor %}

View File

@@ -16,7 +16,7 @@
<p>{% trans %}The user and their data will be deleted irrevocably, permanently and immediately upon pushing the below button. <strong>There is no way back!</strong>{% endtrans %}</p>
{% endcall %}
<div class="f-bbox">
{%- call standard_button("back", url_for(".index"), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call standard_button("back", url_for(".edit_user", localpart=target_user.localpart), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%}
</div>
</form></div>

View File

@@ -1,5 +1,5 @@
{% extends "admin_app.html" %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon %}
{% block head_lead %}
{{ super() }}
{% include "copy-snippet.html" %}
@@ -40,8 +40,8 @@
{%- endif -%}
</div>
<div class="f-bbox">
{%- call standard_button("back", url_for(".circles"), class="secondary") -%}
{% trans %}Back{% endtrans %}
{%- call standard_button("back", url_for(".circles"), class="tertiary") -%}
{% trans %}Return to circle list{% endtrans %}
{%- endcall -%}
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
</div>
@@ -56,14 +56,21 @@
{%- if circle_members -%}
<div class="el-2 elevated"><table>
<thead>
<th>Login name</th>
<th class="collapsible">Display name</th>
<th>Actions</th>
<th>{% trans %}Login name{% endtrans %}</th>
<th class="collapsible">{% trans %}Display name{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</thead>
<tbody>
{%- for member in circle_members -%}
{%- for localpart, member in circle_members -%}
<tr>
<td>{{ member.localpart }}</td>
<td>
{%- if member -%}
{{ localpart }}
{%- else -%}
{{ localpart }}
<span class="with-tooltip above" data-tooltip="{% trans %}The user has been deleted from the server.{% endtrans %}"><em> ({% trans %}deleted{% endtrans %})</em></span>
{%- endif -%}
</td>
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
<td class="nowrap">
{%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}

View File

@@ -44,10 +44,10 @@
<dd>{{ invite.created_at | format_date }}</dd>
</dl>
<div class="f-bbox">
{%- call form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%}
{%- call standard_button("back", url_for(".invitations"), class="primary") %}
{% trans %}Back{% endtrans %}
{%- call standard_button("back", url_for(".invitations"), class="tertiary") %}
{% trans %}Return to invitation list{% endtrans %}
{%- endcall %}
{%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%}
</div>
</div>
</form>

View File

@@ -0,0 +1,78 @@
{% extends "admin_app.html" %}
{% from "library.j2" import box, form_button, standard_button, icon %}
{% macro access_level_description(role, caller=None) %}
{%- if role == "prosody:restricted" -%}
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
{%- elif role == "prosody:normal" -%}
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
{%- elif role == "prosody:admin" -%}
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}
{%- endif -%}
{% endmacro %}
{% macro access_level_icon(role, caller=None) %}
{%- if role == "prosody:restricted" -%}
{% call icon("lock") %}{% endcall %}
{%- elif role == "prosody:admin" -%}
{% call icon("admin") %}{% endcall %}
{%- endif -%}
{% endmacro %}
{% block content %}
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
<h2 class="form-title">{% trans %}Edit user{% endtrans %}</h2>
<div class="f-ebox">
{{ form.localpart.label }}
{{ form.localpart(readonly="readonly") }}
<p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p>
</div>
<div class="f-ebox">
{{ form.display_name.label }}
{{ form.display_name }}
</div>
<h3 class="form-title">{% trans %}Access Level{% endtrans %}</h3>
<p class="form-descr weak">{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}</p>
<div class="f-ebox">
<fieldset>{#- -#}
<legend class="a11y-only">{{ form.role.label.text }}</legend>
{%- for level in form.role -%}
<div class="radio-button-ext">
{{ level }}<label for="{{ level.id }}">
{%- trans title=level.label.text, icon=access_level_icon(level.data), description=access_level_description(level.data) -%}
<strong>{{ title }}{{ icon }}</strong><p>{{ description }}</p>
{%- endtrans -%}
</label>
</div>
{%- endfor -%}
</fieldset>
</div>
<div class="f-bbox">
{%- call standard_button("back", url_for(".users"), class="tertiary") -%}
{%- trans -%}Return to user list{%- endtrans -%}
{%- endcall -%}
{%- call standard_button("delete", url_for(".delete_user", localpart=target_user.localpart), class="secondary") -%}
{%- trans -%}Delete user{%- endtrans -%}
{%- endcall -%}
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
</div>
</div>
<h2>{% trans %}Further actions{% endtrans %}</h2>
<div class="form layout-expanded">
<h2 class="form-title">{% trans %}Reset password{% endtrans %}</h2>
{{ form.csrf_token }}
<p class="form-desc">
{% trans %}If the user has lost their password, you can use the button below to create a special link which allows to change the password of the account, once.{% endtrans %}
</p>
<div class="f-bbox">
{%- call form_button("passwd", form.action_create_reset, class="primary") -%}{%- endcall -%}
</div>
<h2 class="form-title">{% trans %}Debug information{% endtrans %}</h2>
<p class="form-desc">
{% trans %}In some cases, extended information about the user account and the connected devices is necessary to troubleshoot issues. The button below reveals this (sensitive) information.{% endtrans %}
</p>
<div class="f-bbox">
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="primary") -%}
{%- trans -%}Show debug information{%- endtrans -%}
{%- endcall -%}
</div>
</div></form>
{% endblock %}

View File

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

View File

@@ -9,7 +9,7 @@
<form method="POST">
{{- form.csrf_token -}}
<div class="form layout-expanded">
<h2 class="form-title">{% trans user_name=target_user.localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2>
<h2 class="form-title">{% trans user_name=localpart %}Password reset link for {{ user_name }}{% endtrans %}</h2>
<p class="form-desc">{% trans %}The following link will allow the user to reset their password on their device, once.{% endtrans %}</p>
<dd>
<dt>{% trans %}Valid until{% endtrans %}</dt>
@@ -21,7 +21,7 @@
{%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%}
{% trans %}Destroy link{% endtrans %}
{%- endcall -%}
{%- call standard_button("back", url_for(".users"), class="primary") -%}
{%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%}
{% trans %}Back{% endtrans %}
{%- endcall -%}
</div>

View File

@@ -0,0 +1,105 @@
{% extends "admin_app.html" %}
{% from "library.j2" import form_button %}
{% block content %}
<h1>{% trans %}Manage system{% endtrans %}</h1>
{% if show_metrics %}
<h2>{% trans %}Overall system status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}System load (5 minute average){% endtrans %}</dt>
<dd>
{%- if metrics.load5 -%}
{{ metrics.load5 }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Memory use{% endtrans %}</dt>
<dd>
{%- if metrics.mem_total and metrics.mem_available -%}
{% trans percentage_global=((1 - (metrics.mem_available / metrics.mem_total)) | format_percent), percentage_snikket=((((metrics.prosody_rss | default(0)) + (metrics.portal_rss | default(0))) / metrics.mem_total) | format_percent), mem_available=(metrics.mem_total | format_bytes) %}{{ percentage_global }} of {{ mem_available }}. Of that, Snikket uses {{ percentage_snikket }}.{% endtrans %}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
<h2>{% trans %}Web portal status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}Version{% endtrans %}</dt>
<dd>{{ version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
<dd>
{%- if metrics.portal_cpu -%}
{{ metrics.portal_cpu | format_percent }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Current memory use{% endtrans %}</dt>
<dd>
{%- if metrics.portal_rss -%}
{{ metrics.portal_rss | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
<h2>{% trans %}Snikket server status{% endtrans %}</h2>
<div class="elevated el-2">
<dl>
<dt>{% trans %}Version{% endtrans %}</dt>
<dd>{{ prosody_version }} <a href="{{ url_for("main.about") }}">{% trans %}View all versions{% endtrans %}</a></dd>
<dt>{% trans %}Average CPU use{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_cpu -%}
{{ metrics.prosody_cpu | format_percent }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Current memory use{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_rss -%}
{{ metrics.prosody_rss | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Storage used by shared files{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_uploads | default(None) is not none -%}
{{ metrics.prosody_uploads | format_bytes }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Connected devices{% endtrans %}</dt>
<dd>
{%- if metrics.prosody_devices | default(None) is not none -%}
{{ metrics.prosody_devices }}
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
</dl>
</div>
{% endif %}
<h2>{% trans %}Broadcast message{% endtrans %}</h2>
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
<p class="form-desc">{% trans %}This form allows you to send a message to all users currently online on your Snikket server. Use it wisely.{% endtrans %}</p>
<div class="f-ebox">
{{ form.text.label }}
{{ form.text }}
</div>
<div class="f-ebox">
{{ form.online_only }}{{ form.online_only.label }}
</div>
<div class="f-bbox">
{%- call form_button("send", form.action_send_preview, class="primary") -%}{%- endcall -%}
{%- call form_button("broadcast", form.action_post_all, class="secondary accent") -%}{%- endcall -%}
</div>
</div></form>
{% endblock %}

View File

@@ -1,9 +1,7 @@
{% extends "admin_app.html" %}
{% from "library.j2" import action_button, value_or_hint, custom_form_button %}
{% from "library.j2" import action_button, icon, value_or_hint, custom_form_button %}
{% block content %}
<h1>{% trans %}Manage users{% endtrans %}</h1>
<form method="POST" action="{{ url_for(".create_password_reset_link") }}">
{{- reset_form.csrf_token -}}
<div class="elevated el-2"><table>
<thead>
<tr>
@@ -15,17 +13,19 @@
<tbody>
{% for user in users %}
<tr>
<td>{{ user.localpart }}</td>
<td>
{{- user.localpart -}}
{%- if user.has_admin_role -%}
<span class="with-tooltip above" data-tooltip="{% trans %}The user is an administrator.{% endtrans %}">{% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %}</span>
{%- endif -%}
{%- if user.has_restricted_role -%}
<span class="with-tooltip above" data-tooltip="{% trans %}The user is restricted.{% endtrans %}">{% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %}</span>
{%- endif -%}
</td>
<td>{% call value_or_hint(user.display_name) %}{% endcall %}</td>
<td class="nowrap">
{%- call action_button("delete", url_for(".delete_user", localpart=user.localpart), class="secondary") -%}
{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}
{%- endcall -%}
{%- call action_button("bug_report", url_for(".debug_user", localpart=user.localpart), class="secondary") -%}
{% trans user_name=user.localpart %}Show debug information for {{ user_name }}{% endtrans %}
{%- endcall -%}
{%- call custom_form_button("passwd", reset_form.action_create.name, user.localpart, class="secondary", slim=True) -%}
{% trans user_name=user.localpart %}Create password reset link for {{ user_name }}{% endtrans %}
{%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%}
{% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %}
{%- endcall -%}
</form>
</td>
@@ -33,6 +33,5 @@
{% endfor %}
</tbody>
</table></div>
</form>
{%- include "admin_create_invite_form.html" -%}
{% endblock %}

View File

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

View File

@@ -17,11 +17,11 @@
{%- call render_errors(form) %}{% endcall -%}
<div class="f-ebox">
{{ form.password.label }}
{{ form.password }}
{{ form.password(autocomplete="new-password") }}
</div>
<div class="f-ebox">
{{ form.password_confirm.label }}
{{ form.password_confirm }}
{{ form.password_confirm(autocomplete="new-password") }}
</div>
<div class="f-bbox">
{%- call form_button("passwd", form.action_reset, class="primary") -%}{%- endcall -%}

View File

@@ -1,6 +1,6 @@
{% extends "invite.html" %}
{% set body_id = "invite" %}
{% from "library.j2" import form_button, clipboard_button %}
{% from "library.j2" import form_button, clipboard_button, render_errors %}
{% block head_lead %}
<title>{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %}</title>
{%- include "copy-snippet.html" -%}
@@ -15,6 +15,47 @@
{% trans %}Copy address{% endtrans %}
{%- endcall -%}
<p>{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}</p>
<p>{% trans %}You can now safely close this page.{% endtrans %}</p>
<p>{% trans login_url=url_for('main.login') %}You can now safely close this page, or log in to the web portal to <a href="{{ login_url }}">manage your account</a>.{% endtrans %}</p>
{% if migration_success %}
<h2>{% trans %}Import successful{% endtrans %}</h2>
<p>{% trans %}Congratulations! Your account data has been successfully imported.{% endtrans %}</p>
{% endif %}
{% if form %}
<h2>{% trans %}Moving to Snikket?{% endtrans %}</h2>
<p>{% trans %}If you are moving from a different Snikket instance or another XMPP-compatible service, you may optionally import the data (contacts, profile information, etc.) from your previous account. When you have exported the data from your previous account, upload it using the form below.{% endtrans %}</p>
<div class="form layout-expanded"><form method="POST" enctype="multipart/form-data">
<h3 class="form-title">{% trans %}Upload account data{% endtrans %}</h3>
{{ form.csrf_token }}
{% call render_errors(form) %}{% endcall %}
<div class="f-ebox">
{{ form.account_data_file.label }}
{{ form.account_data_file(accept="application/xml",
data_maxsize=max_import_size,
data_warning_header=import_too_big_warning_header,
data_maxsize_warning=import_too_big_warning) }}
</div>
<div class="f-bbox">
{%- call form_button("upload", form.action_import, class="secondary") %}{% endcall -%}
</div>
<script type="text/javascript">
document.getElementById("{{ form.account_data_file.id }}").onchange = function() {
var maxsize_s = this.dataset.maxsize;
var maxsize = parseInt(maxsize_s);
if (this.files[0].size > maxsize) {
var warning_header = this.dataset.warningHeader;
var warning_text = this.dataset.maxsizeWarning;
this.setCustomValidity(warning_text);
this.reportValidity();
this.value = null;
} else {
this.setCustomValidity("");
}
};
</script>
</form></div>
{% endif %}
</div>
{% endblock %}

View File

@@ -56,29 +56,7 @@
{%- endcall -%}
</header>
<p>{% trans %}You can transfer this invite to your mobile device by scanning a code with your camera. You can use either a QR scanner app or the Snikket app itself.{% endtrans %}</p>
<div class="tabbox">
{#- -#}
<nav class="tabs" role="tablist">
{#- -#}
<a href="#qr-info-url" class="active" role="tab" aria-controls="qr-info-url" aria-selected="true" onclick="select_tab(this); return false;">{% trans %}Using a QR code scanner{% endtrans %}</a>
{#- -#}
<a href="#qr-info-uri" role="tab" aria-controls="qr-info-uri" aria-selected="false" onclick="select_tab(this); return false;">{% trans %}Using the Snikket app{% endtrans %}</a>
{#- -#}
</nav>
{#- -#}
<div id="qr-info-url" class="tab-pane active">
<p>{% trans %}Use a <em>QR code</em> scanner on your mobile device to scan the code below:{% endtrans %}</p>
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True) }}" class="qr"></div>
</div>
{#- -#}
<div id="qr-info-uri" class="tab-pane">
<img class="float-right" id="tutorial-scan" aria-hidden="true" alt="" src="{{ url_for("static", filename="img/tutorial-scan.png") }}">
<p>{% trans %}Install the Snikket app on your mobile device, open it, and tap the 'Scan' button at the top.{% endtrans %}</p>
<p>{% trans %}Your camera will turn on. Point it at the square code below until it is within the highlighted square on your screen, and wait until the app recognises it.{% endtrans %}</p>
<div id="qr-uri" data-qrdata="{{ invite.xmpp_uri }}" class="qr"></div>
</div>
{#- -#}
</div>
<div id="qr-invite-page" data-qrdata="{{ url_for(".view", id_=invite_id, _external=True, _scheme="https") }}" class="qr"></div>
{#- -#}
{%- call standard_button("close", "#", onclick="close_modal(this.parentNode.parentNode); return false;", class="primary") -%}
{% trans %}Close{% endtrans %}
@@ -96,9 +74,9 @@
{% 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>
<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>
<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>
@@ -127,7 +105,7 @@
<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 }}" class="popover" data-popover-id="fdroid-popover"><img alt='{% trans %}Install via F-Droid{% endtrans %}' src='{{ url_for('static', filename='img/f-droid-badge.png') }}' class="fdroid"/></a></p></li>
<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") -%}

View File

@@ -30,6 +30,7 @@
<div>
<div>{% call standard_button("edit", url_for(".profile"), class="primary") %}{% trans %}Edit profile{% endtrans %}{% endcall %}</div>
<div>{% call standard_button("passwd", url_for(".change_pw"), class="secondary") %}{% trans %}Change password{% endtrans %}{% endcall %}</div>
<div>{% call standard_button("folder", url_for(".manage_data"), class="secondary") %}{% trans %}Manage your data{% endtrans %}{% endcall %}</div>
</div>
{#- -#}
</li>

View File

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

View File

@@ -0,0 +1,24 @@
{% extends "app.html" %}
{% from "library.j2" import standard_button, form_button, render_errors, avatar with context %}
{% block content %}
<h1>{% trans %}Manage your data{% endtrans %}</h1>
<nav class="welcome">
<ul>
<li>
<h2>{% trans %}Export account{% endtrans %}</h2>
<p>{% trans %}Download your account data as a file for backup purposes or to move your account to another service.{% endtrans %}</p>
{% call render_errors(form) %}{% endcall %}
<div class="f-bbox">
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
<form method="POST">
{{ form.csrf_token }}
{%- call form_button("download", form.action_export, class="primary") %}{% endcall -%}
</form>
</div>
</li>
</ul>
</nav>
{% endblock %}

View File

@@ -9,22 +9,22 @@
{%- endcall -%}
<div class="f-ebox">
{{ form.current_password.label(class="required") }}
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else "")) }}
{{ form.current_password(class=("has-error" if form.current_password.name in form.errors else ""), autocomplete="current-password") }}
</div>
<div class="f-ebox">
{{ form.new_password.label(class="required") }}
{{ form.new_password }}
{{ form.new_password(autocomplete="new-password") }}
</div>
<div class="f-ebox">
{{ form.new_password_confirm.label(class="required") }}
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else "")) }}
{{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else ""), autocomplete="new-password") }}
</div>
<div class="box warning">
<header>{% trans %}Warning{% endtrans %}</header>
<p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p>
</div>
<div class="f-bbox">
{%- call standard_button("back", url_for('.index'), class="secondary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%}
{%- call custom_form_button("passwd", "", "", class="primary") -%}
{% trans %}Change password{% endtrans %}
{%- endcall -%}

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,276 +1,373 @@
# Translations template for PROJECT.
# Copyright (C) 2021 ORGANIZATION
# Copyright (C) 2023 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2023.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-03-20 16:44+0100\n"
"POT-Creation-Date: 2023-03-28 19:16+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n"
"Generated-By: Babel 2.12.1\n"
#: snikket_web/admin.py:59
msgid "Delete user permanently"
#: snikket_web/admin.py:69 snikket_web/templates/admin_delete_user.html:10
#: snikket_web/templates/admin_edit_circle.html:59
#: snikket_web/templates/admin_users.html:8
msgid "Login name"
msgstr ""
#: snikket_web/admin.py:72
msgid "User deleted"
#: snikket_web/admin.py:73 snikket_web/templates/admin_delete_user.html:12
#: snikket_web/templates/admin_edit_circle.html:60
#: snikket_web/templates/admin_users.html:9 snikket_web/user.py:63
msgid "Display name"
msgstr ""
#: snikket_web/admin.py:115
#: snikket_web/admin.py:77 snikket_web/templates/admin_edit_user.html:32
msgid "Access Level"
msgstr ""
#: snikket_web/admin.py:79
msgid "Limited"
msgstr ""
#: snikket_web/admin.py:80
msgid "Normal user"
msgstr ""
#: snikket_web/admin.py:81
msgid "Administrator"
msgstr ""
#: snikket_web/admin.py:86
msgid "Update user"
msgstr ""
#: snikket_web/admin.py:90
msgid "Create password reset link"
msgstr ""
#: snikket_web/admin.py:108
msgid "Password reset link created"
msgstr ""
#: snikket_web/admin.py:121
#: snikket_web/admin.py:123
msgid "User information updated."
msgstr ""
#: snikket_web/admin.py:145
msgid "Delete user permanently"
msgstr ""
#: snikket_web/admin.py:158
msgid "User deleted"
msgstr ""
#: snikket_web/admin.py:196
msgid "Password reset link not found"
msgstr ""
#: snikket_web/admin.py:208
msgid "Password reset link deleted"
msgstr ""
#: snikket_web/admin.py:140
#: snikket_web/admin.py:228
msgid "Invite to circle"
msgstr ""
#: snikket_web/admin.py:146
#: snikket_web/admin.py:234
msgid "At least one circle must be selected"
msgstr ""
#: snikket_web/admin.py:151
#: snikket_web/admin.py:239
msgid "Valid for"
msgstr ""
#: snikket_web/admin.py:153
#: snikket_web/admin.py:241
msgid "One hour"
msgstr ""
#: snikket_web/admin.py:154
#: snikket_web/admin.py:242
msgid "Twelve hours"
msgstr ""
#: snikket_web/admin.py:155
#: snikket_web/admin.py:243
msgid "One day"
msgstr ""
#: snikket_web/admin.py:156
#: snikket_web/admin.py:244
msgid "One week"
msgstr ""
#: snikket_web/admin.py:157
#: snikket_web/admin.py:245
msgid "Four weeks"
msgstr ""
#: snikket_web/admin.py:163 snikket_web/templates/admin_edit_invite.html:17
#: snikket_web/admin.py:251 snikket_web/templates/admin_edit_invite.html:17
msgid "Invitation type"
msgstr ""
#: snikket_web/admin.py:165 snikket_web/templates/library.j2:116
#: snikket_web/admin.py:253 snikket_web/templates/library.j2:116
msgid "Individual"
msgstr ""
#: snikket_web/admin.py:166 snikket_web/templates/library.j2:114
#: snikket_web/admin.py:254 snikket_web/templates/library.j2:114
msgid "Group"
msgstr ""
#: snikket_web/admin.py:172
#: snikket_web/admin.py:260
msgid "New invitation link"
msgstr ""
#: snikket_web/admin.py:234
#: snikket_web/admin.py:322
msgid "Revoke"
msgstr ""
#: snikket_web/admin.py:258
#: snikket_web/admin.py:346
msgid "Invitation created"
msgstr ""
#: snikket_web/admin.py:274
#: snikket_web/admin.py:362
msgid "No such invitation exists"
msgstr ""
#: snikket_web/admin.py:289
#: snikket_web/admin.py:377
msgid "Invitation revoked"
msgstr ""
#: snikket_web/admin.py:306 snikket_web/admin.py:354
#: snikket_web/admin.py:394 snikket_web/admin.py:442
msgid "Name"
msgstr ""
#: snikket_web/admin.py:311 snikket_web/templates/admin_circles.html:47
#: snikket_web/admin.py:399 snikket_web/templates/admin_circles.html:47
msgid "Create circle"
msgstr ""
#: snikket_web/admin.py:341
#: snikket_web/admin.py:429
msgid "Circle created"
msgstr ""
#: snikket_web/admin.py:359
#: snikket_web/admin.py:447
msgid "Select user"
msgstr ""
#: snikket_web/admin.py:364
#: snikket_web/admin.py:452
msgid "Update circle"
msgstr ""
#: snikket_web/admin.py:368
#: snikket_web/admin.py:456
msgid "Delete circle permanently"
msgstr ""
#: snikket_web/admin.py:374
#: snikket_web/admin.py:462
msgid "Add user"
msgstr ""
#: snikket_web/admin.py:390
#: snikket_web/admin.py:478
msgid "No such circle exists"
msgstr ""
#: snikket_web/admin.py:427
#: snikket_web/admin.py:515
msgid "Circle data updated"
msgstr ""
#: snikket_web/admin.py:433
#: snikket_web/admin.py:521
msgid "Circle deleted"
msgstr ""
#: snikket_web/admin.py:444
#: snikket_web/admin.py:532
msgid "User added to circle"
msgstr ""
#: snikket_web/admin.py:453
#: snikket_web/admin.py:541
msgid "User removed from circle"
msgstr ""
#: snikket_web/infra.py:41
#: snikket_web/admin.py:610
msgid "Message contents"
msgstr ""
#: snikket_web/admin.py:616
msgid "Only send to online users"
msgstr ""
#: snikket_web/admin.py:620
msgid "Post to all users"
msgstr ""
#: snikket_web/admin.py:624
msgid "Send preview to yourself"
msgstr ""
#: snikket_web/admin.py:646
msgid "Announcement sent!"
msgstr ""
#: snikket_web/infra.py:53
msgid "Main"
msgstr ""
#: snikket_web/invite.py:106
#: snikket_web/invite.py:35
msgid ""
"The account data you tried to import is too large to upload. Please "
"contact your Snikket operator."
msgstr ""
#: snikket_web/invite.py:114
msgid "Username"
msgstr ""
#: snikket_web/invite.py:110 snikket_web/invite.py:177 snikket_web/main.py:41
#: snikket_web/invite.py:118 snikket_web/invite.py:186 snikket_web/main.py:43
msgid "Password"
msgstr ""
#: snikket_web/invite.py:114 snikket_web/invite.py:181
#: snikket_web/invite.py:122 snikket_web/invite.py:190
msgid "Confirm password"
msgstr ""
#: snikket_web/invite.py:118 snikket_web/invite.py:185
#: snikket_web/invite.py:126 snikket_web/invite.py:194
msgid "The passwords must match."
msgstr ""
#: snikket_web/invite.py:123
#: snikket_web/invite.py:131
msgid "Create account"
msgstr ""
#: snikket_web/invite.py:150
#: snikket_web/invite.py:158
msgid "That username is already taken."
msgstr ""
#: snikket_web/invite.py:154 snikket_web/invite.py:218
#: snikket_web/invite.py:162 snikket_web/invite.py:227
msgid "Registration was declined for unknown reasons."
msgstr ""
#: snikket_web/invite.py:158
#: snikket_web/invite.py:166
msgid "The username is not valid."
msgstr ""
#: snikket_web/invite.py:190 snikket_web/templates/user_home.html:32
#: snikket_web/invite.py:199 snikket_web/templates/user_home.html:32
#: snikket_web/templates/user_passwd.html:29
msgid "Change password"
msgstr ""
#: snikket_web/main.py:36
#: snikket_web/invite.py:246
msgid "Account data file"
msgstr ""
#: snikket_web/invite.py:250
msgid "Import data"
msgstr ""
#: snikket_web/invite.py:271
#, python-format
msgid ""
"The account data you tried to import is in an unknown format. Please "
"upload an XML file in XEP-0227 format (provided format: %(mimetype)s)."
msgstr ""
#: snikket_web/invite.py:291 snikket_web/templates/unauth.html:18
#: snikket_web/user.py:178
msgid "Error"
msgstr ""
#: snikket_web/main.py:38
msgid "Address"
msgstr ""
#: snikket_web/main.py:46
#: snikket_web/main.py:48
msgid "Sign in"
msgstr ""
#: snikket_web/main.py:55
#: snikket_web/main.py:57
msgid "Invalid username or password."
msgstr ""
#: snikket_web/main.py:83
#: snikket_web/main.py:85
msgid "Login successful!"
msgstr ""
#: snikket_web/user.py:27
#: snikket_web/user.py:29
msgid "Current password"
msgstr ""
#: snikket_web/user.py:32
#: snikket_web/user.py:34
msgid "New password"
msgstr ""
#: snikket_web/user.py:37
#: snikket_web/user.py:39
msgid "Confirm new password"
msgstr ""
#: snikket_web/user.py:41
#: snikket_web/user.py:43
msgid "The new passwords must match."
msgstr ""
#: snikket_web/user.py:48
#: snikket_web/user.py:50
msgid "Sign out"
msgstr ""
#: snikket_web/user.py:53
#: snikket_web/user.py:55
msgid "Nobody"
msgstr ""
#: snikket_web/user.py:54
#: snikket_web/user.py:56
msgid "Friends only"
msgstr ""
#: snikket_web/user.py:55
#: snikket_web/user.py:57
msgid "Everyone"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:12
#: snikket_web/templates/admin_users.html:11 snikket_web/user.py:61
msgid "Display name"
msgstr ""
#: snikket_web/user.py:65
#: snikket_web/user.py:67
msgid "Avatar"
msgstr ""
#: snikket_web/user.py:69
#: snikket_web/user.py:71
msgid "Profile visibility"
msgstr ""
#: snikket_web/user.py:74
#: snikket_web/user.py:76
msgid "Update profile"
msgstr ""
#: snikket_web/user.py:99
msgid "Incorrect password."
#: snikket_web/user.py:82
msgid "Account data"
msgstr ""
#: snikket_web/user.py:103
msgid "Password changed"
#: snikket_web/user.py:86
msgid "Upload"
msgstr ""
#: snikket_web/user.py:111
msgid "Incorrect password."
msgstr ""
#: snikket_web/user.py:115
msgid "Password changed"
msgstr ""
#: snikket_web/user.py:123
msgid ""
"The chosen avatar is too big. To be able to upload larger avatars, please"
" use the app."
msgstr ""
#: snikket_web/user.py:159
#: snikket_web/user.py:170
msgid "Profile updated"
msgstr ""
#: snikket_web/templates/unauth.html:18 snikket_web/user.py:167
msgid "Error"
#: snikket_web/user.py:184
msgid "Export"
msgstr ""
#: snikket_web/user.py:202
msgid "You currently have no account data to export."
msgstr ""
#: snikket_web/templates/_footer.html:4
@@ -348,7 +445,7 @@ msgstr ""
msgid "Software Versions"
msgstr ""
#: snikket_web/templates/about.html:29
#: snikket_web/templates/about.html:32
msgid "Back to the main page"
msgstr ""
@@ -383,8 +480,9 @@ msgid "Members"
msgstr ""
#: snikket_web/templates/admin_circles.html:15
#: snikket_web/templates/admin_edit_circle.html:61
#: snikket_web/templates/admin_invites.html:24
#: snikket_web/templates/admin_users.html:12
#: snikket_web/templates/admin_users.html:10
msgid "Actions"
msgstr ""
@@ -455,12 +553,12 @@ msgid "Copy complete output"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:4
#: snikket_web/templates/admin_users.html:22
#, python-format
msgid "Delete user %(user_name)s"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:6
#: snikket_web/templates/admin_edit_user.html:53
msgid "Delete user"
msgstr ""
@@ -468,11 +566,6 @@ msgstr ""
msgid "Are you sure you want to delete the following user?"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:10
#: snikket_web/templates/admin_users.html:10
msgid "Login name"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:15
msgid "Danger"
msgstr ""
@@ -485,10 +578,9 @@ msgid ""
msgstr ""
#: snikket_web/templates/admin_delete_user.html:19
#: snikket_web/templates/admin_edit_circle.html:44
#: snikket_web/templates/admin_edit_invite.html:49
#: snikket_web/templates/admin_reset_user_password.html:25
#: snikket_web/templates/user_logout.html:10
#: snikket_web/templates/user_manage_data.html:14
#: snikket_web/templates/user_passwd.html:27
#: snikket_web/templates/user_profile.html:32
msgid "Back"
@@ -522,6 +614,10 @@ msgstr ""
msgid "This circle has no group chat associated."
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:44
msgid "Return to circle list"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:48
msgid "Delete circle"
msgstr ""
@@ -534,28 +630,37 @@ msgstr ""
msgid "Circle members"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:70
#: snikket_web/templates/admin_edit_circle.html:71
msgid "The user has been deleted from the server."
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:71
#: snikket_web/templates/library.j2:108
msgid "deleted"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:77
#, python-format
msgid "Remove user %(username)s from circle"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:78
#: snikket_web/templates/admin_edit_circle.html:85
msgid "This circle currently has no members."
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:80
#: snikket_web/templates/admin_edit_circle.html:87
msgid "Invite more members"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:83
#: snikket_web/templates/admin_edit_circle.html:90
msgid "Add existing user"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:94
#: snikket_web/templates/admin_edit_circle.html:101
msgid "All users added"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:95
#: snikket_web/templates/admin_edit_circle.html:102
msgid "All users on this service are already in this circle."
msgstr ""
@@ -604,6 +709,85 @@ msgstr ""
msgid "Created"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:48
msgid "Return to invitation list"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:5
msgid ""
"Limited users can interact with users on the same Snikket service and be "
"members of circles."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:7
msgid ""
"Like limited users and can also interact with users on other Snikket "
"services."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:9
msgid "Like normal users and can access the admin panel in the web portal."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:20
#: snikket_web/templates/admin_users.html:28
#, python-format
msgid "Edit user %(user_name)s"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:22
msgid "Edit user"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:26
msgid "The login name cannot be changed."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:33
msgid ""
"The access level of a user determines what interactions are allowed for "
"them on your Snikket service."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:40
#, python-format
msgid "<strong>%(title)s%(icon)s</strong><p>%(description)s</p>"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:50
msgid "Return to user list"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:58
msgid "Further actions"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:60
msgid "Reset password"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:63
msgid ""
"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."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:68
msgid "Debug information"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:70
msgid ""
"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."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:74
msgid "Show debug information"
msgstr ""
#: snikket_web/templates/admin_home.html:4
msgid "Welcome to the admin panel!"
msgstr ""
@@ -643,11 +827,28 @@ msgstr ""
msgid "Manage invitations"
msgstr ""
#: snikket_web/templates/admin_home.html:36
msgid "Go back to your user's web portal page."
#: snikket_web/templates/admin_home.html:35
msgid "System health"
msgstr ""
#: snikket_web/templates/admin_home.html:38
msgid "View the server status or send a broadcast message to all users."
msgstr ""
#: snikket_web/templates/admin_home.html:40
msgid "Send a broadcast message to all users."
msgstr ""
#: snikket_web/templates/admin_home.html:43
#: snikket_web/templates/admin_system.html:4
msgid "Manage system"
msgstr ""
#: snikket_web/templates/admin_home.html:48
msgid "Go back to your user's web portal page."
msgstr ""
#: snikket_web/templates/admin_home.html:50
msgid "Exit admin panel"
msgstr ""
@@ -698,14 +899,96 @@ msgstr ""
msgid "Destroy link"
msgstr ""
#: snikket_web/templates/admin_users.html:25
#, python-format
msgid "Show debug information for %(user_name)s"
#: snikket_web/templates/admin_system.html:6
msgid "Overall system status"
msgstr ""
#: snikket_web/templates/admin_users.html:28
#: snikket_web/templates/admin_system.html:9
msgid "System load (5 minute average)"
msgstr ""
#: snikket_web/templates/admin_system.html:14
#: snikket_web/templates/admin_system.html:22
#: snikket_web/templates/admin_system.html:37
#: snikket_web/templates/admin_system.html:45
#: snikket_web/templates/admin_system.html:60
#: snikket_web/templates/admin_system.html:68
#: snikket_web/templates/admin_system.html:76
#: snikket_web/templates/admin_system.html:84
msgid "unknown"
msgstr ""
#: snikket_web/templates/admin_system.html:17
msgid "Memory use"
msgstr ""
#: snikket_web/templates/admin_system.html:20
#, python-format
msgid "Create password reset link for %(user_name)s"
msgid ""
"%(percentage_global)s of %(mem_available)s. Of that, Snikket uses "
"%(percentage_snikket)s."
msgstr ""
#: snikket_web/templates/admin_system.html:27
msgid "Web portal status"
msgstr ""
#: snikket_web/templates/admin_system.html:30
#: snikket_web/templates/admin_system.html:53
msgid "Version"
msgstr ""
#: snikket_web/templates/admin_system.html:31
#: snikket_web/templates/admin_system.html:54
msgid "View all versions"
msgstr ""
#: snikket_web/templates/admin_system.html:32
#: snikket_web/templates/admin_system.html:55
msgid "Average CPU use"
msgstr ""
#: snikket_web/templates/admin_system.html:40
#: snikket_web/templates/admin_system.html:63
msgid "Current memory use"
msgstr ""
#: snikket_web/templates/admin_system.html:50
msgid "Snikket server status"
msgstr ""
#: snikket_web/templates/admin_system.html:71
msgid "Storage used by shared files"
msgstr ""
#: snikket_web/templates/admin_system.html:79
msgid "Connected devices"
msgstr ""
#: snikket_web/templates/admin_system.html:90
msgid "Broadcast message"
msgstr ""
#: snikket_web/templates/admin_system.html:92
msgid ""
"This form allows you to send a message to all users currently online on "
"your Snikket server. Use it wisely."
msgstr ""
#: snikket_web/templates/admin_users.html:19
msgid "The user is an administrator."
msgstr ""
#: snikket_web/templates/admin_users.html:19
msgid " (Administrator)"
msgstr ""
#: snikket_web/templates/admin_users.html:22
msgid "The user is restricted."
msgstr ""
#: snikket_web/templates/admin_users.html:22
msgid " (Restricted)"
msgstr ""
#: snikket_web/templates/app.html:4
@@ -796,8 +1079,8 @@ msgstr ""
#: snikket_web/templates/invite_register.html:16
#: snikket_web/templates/invite_reset_view.html:21
#: snikket_web/templates/invite_view.html:41
#: snikket_web/templates/invite_view.html:106
#: snikket_web/templates/invite_view.html:134
#: snikket_web/templates/invite_view.html:84
#: snikket_web/templates/invite_view.html:112
msgid "Open the app"
msgstr ""
@@ -858,7 +1141,6 @@ msgid "You can now log in using your new password."
msgstr ""
#: snikket_web/templates/invite_reset_success.html:12
#: snikket_web/templates/invite_success.html:18
msgid "You can now safely close this page."
msgstr ""
@@ -888,7 +1170,6 @@ msgid ""
msgstr ""
#: snikket_web/templates/invite_reset_view.html:26
#: snikket_web/templates/invite_view.html:77
msgid ""
"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 "
@@ -936,6 +1217,37 @@ msgid ""
" password you chose during registration."
msgstr ""
#: snikket_web/templates/invite_success.html:18
#, python-format
msgid ""
"You can now safely close this page, or log in to the web portal to <a "
"href=\"%(login_url)s\">manage your account</a>."
msgstr ""
#: snikket_web/templates/invite_success.html:21
msgid "Import successful"
msgstr ""
#: snikket_web/templates/invite_success.html:22
msgid "Congratulations! Your account data has been successfully imported."
msgstr ""
#: snikket_web/templates/invite_success.html:26
msgid "Moving to Snikket?"
msgstr ""
#: snikket_web/templates/invite_success.html:27
msgid ""
"If you are moving from a different Snikket instance or another XMPP-"
"compatible service, you may optionally import the data (contacts, profile"
" information, etc.) from your previous account. When you have exported "
"the data from your previous account, upload it using the form below."
msgstr ""
#: snikket_web/templates/invite_success.html:30
msgid "Upload account data"
msgstr ""
#: snikket_web/templates/invite_view.html:6
#, python-format
msgid "Invite to %(site_name)s | Snikket"
@@ -976,7 +1288,7 @@ msgid "Get it on Google Play"
msgstr ""
#: snikket_web/templates/invite_view.html:30
#: snikket_web/templates/invite_view.html:102
#: snikket_web/templates/invite_view.html:80
msgid "Download on the App Store"
msgstr ""
@@ -1007,11 +1319,11 @@ msgid "Scan invite code"
msgstr ""
#: snikket_web/templates/invite_view.html:55
#: snikket_web/templates/invite_view.html:84
#: snikket_web/templates/invite_view.html:96
#: snikket_web/templates/invite_view.html:112
#: snikket_web/templates/invite_view.html:124
#: snikket_web/templates/invite_view.html:140
#: snikket_web/templates/invite_view.html:62
#: snikket_web/templates/invite_view.html:74
#: snikket_web/templates/invite_view.html:90
#: snikket_web/templates/invite_view.html:102
#: snikket_web/templates/invite_view.html:118
msgid "Close"
msgstr ""
@@ -1022,59 +1334,39 @@ msgid ""
"itself."
msgstr ""
#: snikket_web/templates/invite_view.html:63
msgid "Using a QR code scanner"
msgstr ""
#: snikket_web/templates/invite_view.html:65
msgid "Using the Snikket app"
msgstr ""
#: snikket_web/templates/invite_view.html:70
msgid ""
"Use a <em>QR code</em> scanner on your mobile device to scan the code "
"below:"
msgstr ""
#: snikket_web/templates/invite_view.html:76
msgid ""
"Install the Snikket app on your mobile device, open it, and tap the "
"'Scan' button at the top."
msgstr ""
#: snikket_web/templates/invite_view.html:93
#: snikket_web/templates/invite_view.html:71
msgid "Install on iOS"
msgstr ""
#: snikket_web/templates/invite_view.html:99
#: snikket_web/templates/invite_view.html:77
msgid ""
"After downloading Snikket from the app store, you have to return to this "
"After downloading Snikket from the App Store, you have to return to this "
"invite link and tap on \"Open the app\" to proceed."
msgstr ""
#: snikket_web/templates/invite_view.html:101
msgid "First download Snikket from the app store using the button below:"
#: snikket_web/templates/invite_view.html:79
msgid "First download Snikket from the App Store using the button below:"
msgstr ""
#: snikket_web/templates/invite_view.html:103
#: snikket_web/templates/invite_view.html:131
#: snikket_web/templates/invite_view.html:81
#: snikket_web/templates/invite_view.html:109
msgid ""
"After the installation is complete, you can return to this page and tap "
"the \"Open the app\" button to continue with the setup:"
msgstr ""
#: snikket_web/templates/invite_view.html:121
#: snikket_web/templates/invite_view.html:130
#: snikket_web/templates/invite_view.html:99
#: snikket_web/templates/invite_view.html:108
msgid "Install via F-Droid"
msgstr ""
#: snikket_web/templates/invite_view.html:127
#: snikket_web/templates/invite_view.html:105
msgid ""
"After installing Snikket via F-Droid, you have to return to this invite "
"link and tap on \"Open the app\" to proceed."
msgstr ""
#: snikket_web/templates/invite_view.html:129
#: snikket_web/templates/invite_view.html:107
msgid "First install Snikket from F-Droid using the button below:"
msgstr ""
@@ -1086,10 +1378,6 @@ msgstr ""
msgid "Invalid input"
msgstr ""
#: snikket_web/templates/library.j2:108
msgid "deleted"
msgstr ""
#: snikket_web/templates/library.j2:122
msgid "Can be used multiple times to create accounts on this Snikket service."
msgstr ""
@@ -1142,15 +1430,20 @@ msgstr ""
msgid "Edit profile"
msgstr ""
#: snikket_web/templates/user_home.html:38
#: snikket_web/templates/user_home.html:33
#: snikket_web/templates/user_manage_data.html:4
msgid "Manage your data"
msgstr ""
#: snikket_web/templates/user_home.html:39
msgid "Your Snikket"
msgstr ""
#: snikket_web/templates/user_home.html:40
#: snikket_web/templates/user_home.html:41
msgid "Manage users, invitations and circles of your Snikket service."
msgstr ""
#: snikket_web/templates/user_home.html:42
#: snikket_web/templates/user_home.html:43
msgid "Admin panel"
msgstr ""
@@ -1164,6 +1457,16 @@ msgid ""
"any other connected devices."
msgstr ""
#: snikket_web/templates/user_manage_data.html:8
msgid "Export account"
msgstr ""
#: snikket_web/templates/user_manage_data.html:9
msgid ""
"Download your account data as a file for backup purposes or to move your "
"account to another service."
msgstr ""
#: snikket_web/templates/user_passwd.html:5
msgid "Change your password"
msgstr ""

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
import asyncio
import typing
import urllib
import quart.flask_patch
from quart import (
Blueprint,
Response,
render_template,
request,
redirect,
@@ -11,7 +13,7 @@ from quart import (
flash,
current_app,
)
import quart.exceptions
import werkzeug.exceptions
import wtforms
@@ -57,7 +59,7 @@ _ACCESS_MODEL_CHOICES = [
class ProfileForm(BaseForm):
nickname = wtforms.TextField(
nickname = wtforms.StringField(
_l("Display name"),
)
@@ -75,6 +77,16 @@ class ProfileForm(BaseForm):
)
class ImportAccountDataForm(BaseForm):
account_data_file = wtforms.FileField(
_l("Account data")
)
action_upload = wtforms.SubmitField(
_l("Upload"),
)
@bp.route("/")
@client.require_session()
async def index() -> str:
@@ -84,7 +96,7 @@ async def index() -> str:
@bp.route('/passwd', methods=["GET", "POST"])
@client.require_session()
async def change_pw() -> typing.Union[str, quart.Response]:
async def change_pw() -> typing.Union[str, werkzeug.Response]:
form = ChangePasswordForm()
if form.validate_on_submit():
try:
@@ -92,8 +104,8 @@ async def change_pw() -> typing.Union[str, quart.Response]:
form.current_password.data,
form.new_password.data,
)
except (quart.exceptions.Unauthorized,
quart.exceptions.Forbidden):
except (werkzeug.exceptions.Unauthorized,
werkzeug.exceptions.Forbidden):
# server refused current password, set an appropriate error
form.current_password.errors.append(
_("Incorrect password."),
@@ -116,7 +128,7 @@ EAVATARTOOBIG = _l(
@bp.route("/profile", methods=["GET", "POST"])
@client.require_session()
async def profile() -> typing.Union[str, quart.Response]:
async def profile() -> typing.Union[str, werkzeug.Response]:
max_avatar_size = current_app.config["MAX_AVATAR_SIZE"]
form = ProfileForm()
@@ -138,7 +150,6 @@ async def profile() -> typing.Union[str, quart.Response]:
mimetype = file_info.mimetype
data = file_info.stream.read()
if len(data) > max_avatar_size:
print(len(data), max_avatar_size)
form.avatar.errors.append(EAVATARTOOBIG)
ok = False
elif len(data) > 0:
@@ -168,9 +179,49 @@ async def profile() -> typing.Union[str, quart.Response]:
avatar_too_big_warning=EAVATARTOOBIG)
class DataExportForm(BaseForm):
action_export = wtforms.SubmitField(
_l("Export")
)
@bp.route("/manage_data", methods=["GET", "POST"])
@client.require_session()
async def manage_data() -> typing.Union[str, quart.Response]:
form = DataExportForm()
if form.validate_on_submit():
user_info = await client.get_user_info()
# The UTF-8 version of the filename needs to be percent-encoded
encoded_address = urllib.parse.quote(
user_info["address"].encode(encoding='utf-8', errors='strict')
)
account_data = await client.export_account_data()
if account_data is None:
await flash(
_("You currently have no account data to export."),
"alert"
)
else:
return Response(account_data,
mimetype="application/xml",
headers={
# We provide the UTF-8 filename, but the ASCII
# one will be used as a fallback for legacy
# browsers (RFC 5987)
"Content-Disposition": (
'attachment; filename="account-data.xml"; '
'filename*="UTF-8\'\'account-data-{}.xml"'
).format(encoded_address)
})
return await render_template("user_manage_data.html",
form=form,
)
@bp.route("/logout", methods=["GET", "POST"])
@client.require_session()
async def logout() -> typing.Union[quart.Response, str]:
async def logout() -> typing.Union[werkzeug.Response, str]:
form = LogoutForm()
if form.validate_on_submit():
await client.logout()

View File

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

View File

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