Compare commits

...

164 Commits

Author SHA1 Message Date
misiek
0f41aa24d8 Translated using Weblate (Polish)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2024-05-07 17:04:52 +00:00
J👀
15516cdaa5 Translated using Weblate (Spanish)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/es/
2024-05-06 05:04:46 +00:00
Rosebud
948e415dbd Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/zh_Hans/
2024-05-02 21:15:30 +00:00
Kim Alvefur
a3fcf7d1d4 Translated using Weblate (Swedish)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2024-05-02 21:15:28 +00:00
Federico
65de73f1fe Translated using Weblate (Italian)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2024-05-02 21:15:28 +00:00
Roberto Resoli
989fe7b5b6 Translated using Weblate (Italian)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2024-05-02 21:15:27 +00:00
Andrey
4bc929e1ce Translated using Weblate (Russian)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2024-05-02 21:15:27 +00:00
BetaRays
5817b24c48 Translated using Weblate (French)
Currently translated at 100.0% (373 of 373 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2024-05-02 21:15:19 +00:00
Weblate
550526efc9 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/
2024-04-30 09:56:03 +00:00
Kim Alvefur
2a2e36ade2 Translated using Weblate (Swedish)
Currently translated at 100.0% (370 of 370 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2024-04-30 09:56:01 +00:00
Andrey
22f7d6f36a Translated using Weblate (Russian)
Currently translated at 100.0% (370 of 370 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2024-04-30 09:56:01 +00:00
Matthew Wild
2d42099017 Merge pull request #188 from snikket-im/invitation-ui
Invitation admin UI improvements
2024-04-30 10:55:44 +01:00
Matthew Wild
2ff47c486a Update translation strings 2024-04-30 10:52:52 +01:00
Matthew Wild
338ee0b278 Add 'share' button for browsers supporting Web Share API 2024-04-30 10:48:51 +01:00
Matthew Wild
64c6548a48 Support for optional text notes on invitations 2024-04-29 18:39:06 +01:00
Matthew Wild
8c824149cc Fixes for invitation display
- Reorder columns, from generic to specific
- Fix empty tooltip on invitation types caused by incorrect macro usage
2024-04-29 18:19:07 +01:00
Matthew Wild
607863cfc4 Remove duplicate template macro 2024-04-29 18:00:22 +01:00
Matthew Wild
13c5d44544 Merge pull request #187 from snikket-im/cookie-samesite-attribute
Explicitly set cookie SameSite attribute to Lax
2024-04-29 11:22:21 +01:00
Matthew Wild
6407eb90db Explicitly set cookie SameSite attribute to Lax
With 'Secure' set, it may default to 'None', which we don't need or want.

'Strict' is not suitable for session cookies - the user would see the login
screen when navigating from another site (e.g. hosting dashboard) and we
already have CSRF protection on forms.
2024-04-29 11:18:55 +01:00
Matthew Wild
a8c6b1a70c Merge pull request #186 from snikket-im/cookie-secure-attribute
Add 'secure' attribute to session cookies
2024-04-29 11:09:44 +01:00
Matthew Wild
67c94bb045 Add 'secure' attribute to session cookies 2024-04-29 11:08:30 +01:00
Weblate
f4c1173a34 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/
2024-04-28 08:40:16 +00:00
Jonas Schäfer
e39b0082b1 Merge pull request #185 from Zash/translate-welcome
Translate welcome message
2024-04-28 10:39:57 +02:00
Kim Alvefur
9eb187a951 Make welcome message translatable 2024-04-27 14:22:39 +02:00
Kim Alvefur
b928e74a74 make extract_translations 2024-04-27 14:21:32 +02:00
Andrey
75c0f504d0 Translated using Weblate (Russian)
Currently translated at 100.0% (368 of 368 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2024-04-23 17:14:06 +00:00
Matthew Wild
7c0310a141 Merge pull request #184 from Zash/really-fix-default-invite-role
Specify a default role in invite form
2024-04-19 14:50:01 +01:00
Kim Alvefur
5e2e645787 Specify a default role in invite form
Actually in the invite form this time
2024-04-19 15:48:40 +02:00
Weblate
9b31894e85 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/
2024-04-19 13:38:57 +00:00
Kim Alvefur
a4472e1a44 Translated using Weblate (Swedish)
Currently translated at 100.0% (362 of 362 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2024-04-19 13:38:56 +00:00
Matthew Wild
b99cae84de Update translations 2024-04-19 14:38:45 +01:00
Matthew Wild
1cac19e4c9 Merge pull request #183 from Zash/default-invite-role
Specify a default role in invite form
2024-04-19 14:37:22 +01:00
Kim Alvefur
d4883765b2 Specify a default role in invite form
The role creation appears to fail without an error, only refreshing the
page unless a role is selected.
2024-04-19 15:30:20 +02:00
Weblate
041f26274b 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/
2024-04-17 08:41:42 +00:00
Matthew Wild
82db30ffd9 Merge pull request #182 from snikket-im/invitation-improvements
Allow selecting a role when creating an invitation
2024-04-17 09:41:31 +01:00
Matthew Wild
b8684329b4 Fix syntax error in template 2024-04-16 21:30:19 +01:00
Matthew Wild
7e26b5f994 Update translations 2024-04-16 21:22:07 +01:00
Matthew Wild
4bdcb46a8a Allow selecting a role when creating an invitation
Includes some reorganization and prettification of the creation form.
2024-04-16 21:16:06 +01:00
Matthew Wild
ed6f413c18 Don't fail if active user metrics are unavailable 2024-04-16 20:42:48 +01:00
Weblate
f63549ee87 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/
2024-04-16 14:51:58 +00:00
Matthew Wild
bd71ab1449 Update translations 2024-04-16 15:51:47 +01:00
Matthew Wild
220bf9994b Show invitation hint when user is alone on their instance 2024-04-16 15:09:16 +01:00
Matthew Wild
33d28e5890 Show active user counts in instance metrics 2024-04-16 15:09:16 +01:00
Matthew Wild
f0f0fa15c9 Small clarifications to the invitation type UI 2024-04-16 15:09:16 +01:00
Matthew Wild
30a9a6816f prosodyclient: Skip adding metadata of broken avatars 2024-04-16 15:09:16 +01:00
Sergio Moreno López
970b8fa7f1 Translated using Weblate (Spanish)
Currently translated at 39.6% (143 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/es/
2024-04-14 21:02:20 +00:00
Sergio Moreno López
629d725ff5 Translated using Weblate (Spanish)
Currently translated at 36.0% (130 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/es/
2024-04-05 21:27:23 +00:00
Sergio Moreno López
6998e66b22 Translated using Weblate (Spanish)
Currently translated at 29.9% (108 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/es/
2024-03-31 22:27:22 +00:00
Matthew Wild
c668c4c56a Added translation using Weblate (Spanish) 2024-03-29 10:13:30 +00:00
Matthew Wild
a13fbd87a6 Merge pull request #181 from snikket-im/feature/handle-invalid-avatars-gracefully
Handle broken/incorrect avatar metadata gracefully
2024-03-11 15:30:24 +00:00
Jonas Schäfer
7ffcd76cea Handle broken/incorrect avatar metadata gracefully
Fixes #180.
2024-03-10 10:49:29 +01:00
Federico
bda0f52320 Translated using Weblate (Italian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2024-01-20 23:01:47 +00:00
Matthew Wild
5efc2a671e Merge pull request #177 from snikket-im/feature/fix-requirements
Fix python requirements to actually work
2024-01-16 16:12:37 +00:00
Jonas Schäfer
1578654816 Fix python requirements to actually work
Didn't do deep research here, just fitted it against the current Docker
image release.
2024-01-16 16:52:47 +01:00
Matthew Wild
e8ab33e12f Merge pull request #175 from snikket-im/fix/html-tag-typo
templates: Fix typo in closing form tag
2024-01-11 15:29:26 +00:00
Matthew Wild
712b0dc502 templates: Fix typo in closing form tag 2024-01-11 15:20:17 +00:00
Matthew Wild
e56c0f9029 github: Strip Generated-By in both places 2024-01-09 15:07:21 +00:00
Weblate
794b48a50b 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/
2024-01-09 14:52:58 +00:00
Matthew Wild
393b30cf5c Remove collapsible flag from column to fix mobile display 2024-01-09 14:52:43 +00:00
Matthew Wild
97198a1da4 Ignore generator name/version when checking if translations are up to date 2024-01-09 14:51:34 +00:00
Weblate
3ba1195fbe 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/
2024-01-09 10:09:07 +00:00
Matthew Wild
121f3eddb5 Merge pull request #174 from snikket-im/fix/password-validation
Fix password validation
2024-01-09 10:08:58 +00:00
Matthew Wild
38ad81b0e2 Validate passwords as early as possible
Prosody now enforces some password policies, including a minimum length of 10
characters. If this fails, we currently show a rather unfriendly error to the
user. By adding this validation, the user should get nicer feedback and never
see that error.

There is a known issue that we don't currently validate all the policies that
Prosody does - for example, Prosody won't accept a password that contains the
username.

Ultimately we should fix the error handling anyway.
2024-01-08 22:58:46 +00:00
Matthew Wild
ec94c64dbc Handle errors on password change 2024-01-08 22:50:36 +00:00
Andrey
28a9a33aa1 Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2024-01-07 16:01:03 +00:00
Kim Alvefur
97eeb85032 Merge pull request #173 from Zash/fix-scope-restricted
Fix login for restricted users
2024-01-07 12:40:26 +01:00
Kim Alvefur
ceef9f024c Fix login for restricted users
Previously this led to an OAuth grant with an empty scope, which could
not be used for anything.
2024-01-07 00:03:38 +01:00
Andrey
40c8b9cc36 Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2024-01-06 05:01:03 +00:00
Matthew Wild
95a8ac1387 Enable Russian and Ukranian languages by default 2024-01-04 14:11:27 +00:00
Roberto Resoli
4c6e26e66b Translated using Weblate (Italian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2024-01-04 10:17:34 +00:00
Andrey
ad2b351a99 Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2024-01-04 10:17:33 +00:00
pep
3bda1f9863 Translated using Weblate (French)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2024-01-04 10:17:33 +00:00
Matthew Wild
f46d95db66 Merge pull request #172 from Zash/form-translation-fix
Fix incomplete translation of Announcement form and Limited user role
2024-01-04 09:05:33 +00:00
Kim Alvefur
ddfdd2fd55 Remove stray HTML closing tag
Where is the <form> ???
2024-01-04 09:54:45 +01:00
Kim Alvefur
17d586e384 Fix translation of "Limited"
Strings in Forms must use lazy_gettext aka _l
2024-01-04 09:54:43 +01:00
Kim Alvefur
dbec07d149 Fix translation of AnnouncementForm
Form translation things must use lazy_gettext aka _l
2024-01-04 09:53:03 +01:00
Weblate
ebf142b505 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/
2024-01-03 16:12:45 +00:00
Matthew Wild
0539d0ab88 translation: Update copyright year to satisfy lint check 2024-01-03 16:12:28 +00:00
Jonas Schäfer
2736bff76b Translated using Weblate (German)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/de/
2024-01-03 16:08:48 +00:00
Dmytro Vozniuk
192601f387 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/uk/
2023-12-25 16:00:58 +00:00
Dmytro Vozniuk
bc9cfeabab Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2023-12-25 16:00:53 +00:00
Dmytro Vozniuk
b770086071 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/uk/
2023-12-24 04:00:53 +00:00
Dmytro Vozniuk
b2c1fdd23b Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2023-12-24 04:00:52 +00:00
Dmytro Vozniuk
906978556e Translated using Weblate (Ukrainian)
Currently translated at 79.2% (286 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/uk/
2023-12-19 13:00:48 +00:00
riccio
274c8e4658 Translated using Weblate (Italian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2023-12-19 13:00:48 +00:00
Dmytro Vozniuk
257a44dac2 Translated using Weblate (Ukrainian)
Currently translated at 29.6% (107 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/uk/
2023-12-17 22:49:22 +00:00
Roberto Resoli
f393a3980b Translated using Weblate (Italian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2023-12-17 22:49:22 +00:00
riccio
badff7eed8 Translated using Weblate (Italian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2023-12-17 22:49:22 +00:00
Andrey
384e07c2a9 Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2023-12-17 22:49:21 +00:00
pep
89724a9712 Translated using Weblate (French)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-17 22:49:21 +00:00
misiek
94f4325f40 Translated using Weblate (Polish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2023-12-17 22:49:21 +00:00
Matthew Wild
af1285b650 Translated using Weblate (Russian)
Currently translated at 99.4% (359 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2023-12-16 14:41:14 +00:00
Andrey
52eba53d8e Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/ru/
2023-12-16 08:38:56 +00:00
pep
94f240687a Translated using Weblate (French)
Currently translated at 99.7% (360 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-16 08:38:56 +00:00
misiek
1b2bdfa881 Translated using Weblate (Polish)
Currently translated at 93.0% (336 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2023-12-16 08:38:56 +00:00
Kim Alvefur
271f450c86 Translated using Weblate (Swedish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2023-12-16 00:19:29 +00:00
Deleted User
6186e8b635 Translated using Weblate (French)
Currently translated at 99.7% (360 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-16 00:19:29 +00:00
pep
dfc6c392c3 Translated using Weblate (French)
Currently translated at 99.7% (360 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-16 00:19:28 +00:00
uira
0ec9a2ae02 Translated using Weblate (Indonesian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/id/
2023-12-15 16:11:00 +00:00
Weblate
09fcf64818 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/
2023-12-15 15:05:45 +00:00
Kim Alvefur
c25db5c3ae Translated using Weblate (Swedish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2023-12-15 15:05:44 +00:00
Deleted User
c85fff7581 Translated using Weblate (French)
Currently translated at 99.7% (360 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-15 15:05:44 +00:00
Link Mauve
039f4b8210 Translated using Weblate (French)
Currently translated at 99.7% (360 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-15 15:05:44 +00:00
uira
7be7ee67c2 Translated using Weblate (Indonesian)
Currently translated at 93.0% (336 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/id/
2023-12-15 15:05:44 +00:00
Jonas Schäfer
6f5fc14dbc Translated using Weblate (German)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/de/
2023-12-15 15:05:44 +00:00
Matthew Wild
65edd3a52b Merge pull request #169 from snikket-im/feature/tweak-user-profile-display
Tweak user profile display
2023-12-15 15:05:36 +00:00
Matthew Wild
ab7149403a Update translations pot (no actual string changes) 2023-12-15 15:04:46 +00:00
Matthew Wild
5b2f3db867 Update user listing in 'edit circle' view to use new user profile layout 2023-12-15 15:00:13 +00:00
Matthew Wild
e12941eab0 Improve user name/JID display
Show the display name more prominently (if one is set), as it is more
"friendly" name. The JID is now displayed instead of just the username, as
this makes it more clear what is being displayed.

The inspiration for this change was the observation that many users on my own
server have the same display name and username, causing a repetitive list
like:

  Jane
  jane

  Robert
  robert

  Sally
  sally

  Steven
  steven

In addition, some accounts do not have a display name set, so it was not
obvious why some people had their name rendered once, and some twice, and why
the capitalization differences.
2023-12-15 14:41:17 +00:00
Andriy Utkin
eda3f4826c Translated using Weblate (Ukrainian)
Currently translated at 6.0% (22 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/uk/
2023-12-15 11:56:32 +00:00
pep
61161eb472 Translated using Weblate (French)
Currently translated at 96.9% (350 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/fr/
2023-12-15 11:56:31 +00:00
Matthew Wild
325826c19b Merge pull request #168 from snikket-im/fix/rc2-runtime-errors
Fix a couple of runtime errors
2023-12-14 15:35:04 +00:00
Matthew Wild
587839f852 Added translation using Weblate (Ukrainian) 2023-12-14 13:11:43 +00:00
Matthew Wild
7411f4a9e1 prosodyclient: Use empty name if none provided by the server
In parallel, I have updated the server to use the group name for groups with
no name (the MUCs with no name are typically the default auto-created group
MUC).
2023-12-14 12:43:59 +00:00
Matthew Wild
d63ae4768a infra: Fix string to use correct translation function name 2023-12-14 12:43:52 +00:00
Weblate
92a8da724f 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/
2023-12-12 18:25:32 +00:00
Matthew Wild
ea3a081b6c Merge pull request #167 from snikket-im/fix/remove-useless-qr-js
Remove broken/needless JS from certain pages
2023-12-12 18:25:20 +00:00
Matthew Wild
0647ba2601 Remove broken/needless JS from certain pages 2023-12-12 18:24:01 +00:00
Kim Alvefur
2769036f94 Translated using Weblate (Swedish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2023-12-09 16:59:59 +00:00
Weblate
c76befad1c 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/
2023-12-08 12:14:28 +00:00
Matthew Wild
74ecfb8653 Merge pull request #166 from snikket-im/feature/admin-users-ui-updates-dec-23
Admin user management UI updates (Dec 23)
2023-12-08 12:14:19 +00:00
Matthew Wild
55b195cd7f Update translations 2023-12-08 12:08:43 +00:00
Matthew Wild
46a7d0c37d Fix some type annotations 2023-12-08 12:06:43 +00:00
Matthew Wild
c63b95c6e0 Align avatar flush with left edge of container 2023-12-08 11:42:30 +00:00
Matthew Wild
6848691141 css: Remove avatar border and round the edges to match the app 2023-12-08 11:42:03 +00:00
Matthew Wild
1e83881a24 Ensure we only have a single primary button to reduce confusion 2023-12-08 11:12:26 +00:00
Matthew Wild
35e6bec328 Improvements for admin user listing view 2023-12-08 11:11:31 +00:00
Matthew Wild
d345f0d98d css: Fix dark mode contrast issue for legend text 2023-12-08 11:11:02 +00:00
Matthew Wild
f5ccb7d858 admin: Support for unlocking/restoring locked/deleted user accounts 2023-12-08 11:10:32 +00:00
Matthew Wild
f7c8bccfa2 import-icons.sh: Use sensible defaults where possible 2023-12-08 10:52:48 +00:00
Matthew Wild
e5d06877a4 prosodyclient: Update for new mod_http_admin_api (5c589fab6f53)
This adds new features including:

- User account enabled/disabled status (read and write)
- Deletion status (if an account is scheduled for deletion)
- Avatar metadata
2023-12-08 10:50:23 +00:00
Matthew Wild
e7ed9dd176 infra: Extend time/date utilities 2023-12-08 10:50:06 +00:00
Matthew Wild
6778557db8 On success, return to user listing (edit is complete) 2023-12-08 10:49:26 +00:00
Matthew Wild
73f3f25515 Add lock_open and restore_from_trash icons 2023-12-08 10:45:08 +00:00
Matthew Wild
bd66600d05 Merge pull request #165 from snikket-im/feature/multiple-circle-mucs
Support circles with multiple group chats, remove default group chat
2023-11-06 14:26:11 +00:00
Matthew Wild
db363367da Support circles with multiple group chats, remove default group chat 2023-11-06 13:52:30 +00:00
Matthew Wild
7ce13b55ac Merge pull request #162 from snikket-im/feature/policies-and-contacts
Add policy URLs and contact addresses for instances in the relevant places
2023-10-25 16:19:44 +01:00
Matthew Wild
d6d4bb5afb Add policy URLs and contact addresses for instances in the relevant places 2023-10-25 16:18:12 +01:00
Kim Alvefur
da52771ebe Merge pull request #161 from Zash/fix-logout
Fix revokation of token on logout
2023-10-21 15:57:46 +02:00
Kim Alvefur
e39b6ca8bb Fix revokation of token on logout
In OAuth 2.0, you don't authenticate with the revocation endpoint using
the token you are revoking, but rather the OAuth client credentials.
2023-10-07 17:39:37 +02:00
Kim Alvefur
14368c5e9a Merge pull request #158 from Zash/prosody-split-user-roles
Update for role changes in Prosody
2023-10-07 14:17:58 +02:00
Kim Alvefur
2cdcf7f282 Update for role changes in Prosody
See https://hg.prosody.im/trunk/rev/082c7d856e61
2023-10-07 12:59:43 +02:00
Kim Alvefur
0f1e76e38c Merge pull request #157 from Zash/debian12
Switch base image to Debian 12
2023-10-07 12:58:58 +02:00
Kim Alvefur
ad9af20f12 Workaround for Flask context change 2023-10-04 23:27:05 +02:00
Kim Alvefur
9672cd6870 Install as many packages as possible from Debian
The only missing piece appears to be environ-config.

This **fails to start** with

```
ImportError: cannot import name '_app_ctx_stack' from 'quart' (/usr/lib/python3/dist-packages/quart/__init__.py)
```
2023-10-04 23:27:05 +02:00
Kim Alvefur
d3a6be7bec Switch base image to Debian 12 2023-08-07 08:47:55 +02:00
Kim Alvefur
7a4b56914c Switch to sassc for CSS building
Because https://github.com/Kronuz/pyScss/pull/426 is not yet in a
release, also just look at the diffstat!
2023-08-02 22:35:32 +02:00
Roberto Resoli
0f74b1b8f2 Translated using Weblate (Italian)
Currently translated at 91.8% (294 of 320 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/it/
2023-07-13 11:17:54 +00:00
misiek
df78e8a8b0 Translated using Weblate (Polish)
Currently translated at 100.0% (320 of 320 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/pl/
2023-04-04 14:11:31 +00:00
Kim Alvefur
77ccdd5eed Translated using Weblate (Swedish)
Currently translated at 100.0% (320 of 320 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/sv/
2023-04-02 15:17:18 +00:00
uira
54b6cad7cd Translated using Weblate (Indonesian)
Currently translated at 100.0% (320 of 320 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/id/
2023-04-02 15:17:15 +00:00
Jonas Schäfer
fbb618c178 Translated using Weblate (German)
Currently translated at 100.0% (320 of 320 strings)

Translation: Snikket/Web Portal
Translate-URL: http://i18n.sotecware.net/projects/snikket/web-portal/de/
2023-04-01 11:13:44 +00:00
Weblate
bd3d56851b 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/
2023-04-01 10:17:56 +00:00
Jonas Schäfer
c475b83c02 Merge pull request #154 from snikket-im/feature/protect-circle-deletion
Add confirmation step to circle deletion
2023-04-01 10:22:23 +02:00
Jonas Schäfer
d9b73055a8 Add confirmation step to circle deletion
Deleting a circle is highly destructive. It removes the group chat
alongside history, as well as the user list. It should definitely be
protected by a confirmation dialogue, I have no clue why it wasn't.

Fixes #153.
2023-04-01 10:08:52 +02:00
Matthew Wild
f37270594e Merge pull request #152 from snikket-im/fix/role-config
Follow new role scheme in Prosody
2023-03-29 20:16:36 +01:00
Jonas Schäfer
fcfcdbeb23 Follow new role scheme in Prosody
Prosody changed its role scheme to only support a single primary role
for each user. In addition, the names of the built-in roles have been
changed. We thus follow those changes to be compatible with the most
recent trunk.

One open question is whether we should switch admin -> operator here,
too (operator being a server-wide admin), but so far there's no need
to.
2023-03-29 18:42:53 +02:00
Jonas Schäfer
fd566b7f30 Merge pull request #151 from snikket-im/fix/user-listing-roles
Make AdminUserInfo compatible with new API
2023-03-28 22:23:06 +02:00
Jonas Schäfer
2762304ae8 Make AdminUserInfo compatible with new API
The mod_http_admin_api changed recently [1], so we need to follow
suit.

Fixes #149.

   [1]: https://hg.prosody.im/prosody-modules/rev/d68348323406
2023-03-28 22:21:07 +02:00
Jonas Schäfer
49bbc3ab09 Merge pull request #148 from Zash/newscopes
Update to match new Prosody scope naming scheme
2023-03-28 21:42:00 +02:00
Kim Alvefur
8f1f80b7d7 Update to match new Prosody scope naming scheme
Ref https://hg.prosody.im/prosody-modules/rev/5ab134b7e510

Thanks Jonas
2023-03-28 21:14:20 +02:00
Jonas Schäfer
13bc283a3e Merge pull request #140 from snikket-im/feature/site-name-consistency
entrypoint: default SNIKKET_WEB_SITE_NAME to SNIKKET_SITE_NAME
2023-03-28 20:59:17 +02:00
Jonas Schäfer
abc0af3918 entrypoint: default SNIKKET_WEB_SITE_NAME to SNIKKET_SITE_NAME
The documentation only talks about SNIKKET_SITE_NAME,
and users thus do not know that they must
set SNIKKET_WEB_SITE_NAME to make
their site name appear on the portal.
2023-03-28 20:54:47 +02:00
Jonas Schäfer
0aff4fc99d Merge pull request #144 from snikket-im/fix/invite-link-header
Fix Link header on invite pages
2023-03-28 20:48:59 +02:00
69 changed files with 14062 additions and 4942 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,14 @@
#!/bin/sh
export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN"
if [ -n "${SNIKKET_SITE_NAME:-}" ]; then
export SNIKKET_WEB_SITE_NAME="$SNIKKET_SITE_NAME"
fi
export SNIKKET_WEB_TOS_URI="${SNIKKET_TOS_URI}"
export SNIKKET_WEB_PRIVACY_URI="${SNIKKET_PRIVACY_URI}"
export SNIKKET_WEB_ABUSE_EMAIL="${SNIKKET_ABUSE_EMAIL}"
export SNIKKET_WEB_SECURITY_EMAIL="${SNIKKET_SECURITY_EMAIL}"
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE-127.0.0.1}"
export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT-5765}"

View File

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

View File

@@ -158,7 +158,9 @@ class AppConfig:
"id",
"it",
"pl",
"ru",
"sv",
"uk",
"zh_Hans_CN",
], converter=autosplit)
apple_store_url = environ.var(
@@ -170,6 +172,10 @@ class AppConfig:
# tools may also very well override it.
max_avatar_size = environ.var(1024*1024, converter=int)
show_metrics = environ.bool_var(True)
tos_uri = environ.var("")
privacy_uri = environ.var("")
abuse_email = environ.var("")
security_email = environ.var("")
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
@@ -202,6 +208,12 @@ def create_app() -> quart.Quart:
app.config["APPLE_STORE_URL"] = config.apple_store_url
app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size
app.config["SHOW_METRICS"] = config.show_metrics
app.config["TOS_URI"] = config.tos_uri
app.config["PRIVACY_URI"] = config.privacy_uri
app.config["ABUSE_EMAIL"] = config.abuse_email
app.config["SECURITY_EMAIL"] = config.security_email
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.context_processor(proc)
app.register_error_handler(

View File

@@ -76,16 +76,25 @@ class EditUserForm(BaseForm):
role = wtforms.RadioField(
_l("Access Level"),
choices=[
("prosody:restricted", _("Limited")),
("prosody:normal", _l("Normal user")),
("prosody:restricted", _l("Limited")),
("prosody:registered", _l("Normal user")),
("prosody:admin", _l("Administrator")),
],
default="prosody:registered",
)
action_save = wtforms.SubmitField(
_l("Update user"),
)
action_restore = wtforms.SubmitField(
_l("Restore account"),
)
action_enable = wtforms.SubmitField(
_l("Unlock account"),
)
action_create_reset = wtforms.SubmitField(
_l("Create password reset link"),
)
@@ -112,18 +121,44 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
".user_password_reset_link",
id_=reset_link.id_,
))
elif form.action_restore.data or form.action_enable.data:
await client.enable_user_account(localpart)
try:
if form.action_restore.data:
await flash(
_("User account restored"),
"success",
)
else:
await flash(
_("User account unlocked"),
"success",
)
return redirect(url_for(".users"))
except aiohttp.ClientResponseError:
if form.action_restore.data:
await flash(
_("Could not restore user account"),
"alert",
)
else:
await flash(
_("Could not unlock user account"),
"alert",
)
return redirect(url_for(".edit_user", localpart=localpart))
await client.update_user(
localpart,
display_name=form.display_name.data,
roles=[form.role.data],
role=form.role.data,
)
await flash(
_("User information updated."),
"success",
)
return redirect(url_for(".edit_user", localpart=localpart))
return redirect(url_for(".users"))
elif request.method == "GET":
form.localpart.data = target_user_info.localpart
@@ -131,7 +166,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
if target_user_info.roles:
form.role.data = target_user_info.roles[0]
else:
form.role.data = "prosody:normal"
form.role.data = "prosody:registered"
return await render_template(
"admin_edit_user.html",
@@ -256,6 +291,20 @@ class InvitePost(BaseForm):
default="account",
)
role = wtforms.RadioField(
_l("Access Level"),
choices=[
("prosody:restricted", _l("Limited")),
("prosody:registered", _l("Normal user")),
("prosody:admin", _l("Administrator")),
],
default="prosody:registered",
)
note = wtforms.StringField(
_l("Comment (optional)"),
)
action_create_invite = wtforms.SubmitField(
_l("New invitation link")
)
@@ -335,12 +384,16 @@ async def create_invite() -> typing.Union[str, werkzeug.Response]:
if form.type_.data == "group":
invite = await client.create_group_invite(
group_ids=form.circles.data,
role_names=[form.role.data],
ttl=form.lifetime.data,
note=form.note.data,
)
else:
invite = await client.create_account_invite(
group_ids=form.circles.data,
role_names=[form.role.data],
ttl=form.lifetime.data,
note=form.note.data,
)
await flash(
_("Invitation created"),
@@ -452,16 +505,14 @@ class EditCircleForm(BaseForm):
_l("Update circle")
)
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
action_remove_user = wtforms.StringField()
action_add_user = wtforms.SubmitField(
_l("Add user")
)
action_remove_group_chat = wtforms.StringField()
@bp.route("/circle/<id_>", methods=["GET", "POST"])
@client.require_admin_session()
@@ -515,13 +566,6 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
_("Circle data updated"),
"success",
)
elif form.action_delete.data:
await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles"))
elif form.action_add_user.data:
if form.user_to_add.data in valid_users:
await client.add_group_member(
@@ -541,6 +585,15 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
_("User removed from circle"),
"success",
)
elif form.action_remove_group_chat.data:
await client.remove_group_chat(
id_,
form.action_remove_group_chat.data,
)
await flash(
_("Chat removed from circle"),
"success",
)
return redirect(url_for(".edit_circle", id_=id_))
@@ -548,11 +601,103 @@ async def edit_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
"admin_edit_circle.html",
target_circle=circle,
form=form,
circle_chats=circle.chats,
circle_members=circle_members,
invite_form=invite_form,
)
class DeleteCircleForm(BaseForm):
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
@bp.route("/circle/<id_>/delete", methods=["GET", "POST"])
@client.require_admin_session()
async def delete_circle(id_: str) -> typing.Union[str, werkzeug.Response]:
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
id_,
session=session,
)
except aiohttp.ClientResponseError as exc:
if exc.status == 404:
await flash(
_("No such circle exists"),
"alert",
)
return redirect(url_for(".circles"))
raise
form = DeleteCircleForm()
if form.validate_on_submit():
if form.action_delete.data:
await client.delete_group(id_)
await flash(
_("Circle deleted"),
"success",
)
return redirect(url_for(".circles"))
return await render_template(
"admin_delete_circle.html",
target_circle=circle,
form=form,
)
class AddCircleChatForm(BaseForm):
name = wtforms.StringField(
_l("Group chat name"),
validators=[wtforms.validators.InputRequired()],
)
action_save = wtforms.SubmitField(
_l("Create group chat")
)
@bp.route("/circle/<id_>/add_chat", methods=["GET", "POST"])
@client.require_admin_session()
async def edit_circle_add_chat(
id_: str
) -> typing.Union[str, werkzeug.Response]:
async with client.authenticated_session() as session:
try:
circle = await client.get_group_by_id(
id_,
session=session,
)
except aiohttp.ClientResponseError as exc:
if exc.status == 404:
await flash(
_("No such circle exists"),
"alert",
)
return redirect(url_for(".circles"))
raise
form = AddCircleChatForm()
if form.validate_on_submit():
if form.action_save.data:
await client.add_group_chat(id_, form.name.data)
await flash(
_("New group chat added to circle"),
"success",
)
return redirect(url_for(".edit_circle", id_=id_))
return await render_template(
"admin_create_circle_chat.html",
target_circle=circle,
group_chat_form=form,
)
_CPU_EPOCH = time.process_time()
_MONOTONIC_EPOCH = time.monotonic()
@@ -607,21 +752,21 @@ def get_system_stats() -> typing.MutableMapping[
class AnnouncementForm(BaseForm):
text = wtforms.StringField(
_("Message contents"),
_l("Message contents"),
widget=wtforms.widgets.TextArea(),
validators=[wtforms.validators.DataRequired()],
)
online_only = wtforms.BooleanField(
_("Only send to online users"),
_l("Only send to online users"),
)
action_post_all = wtforms.SubmitField(
_("Post to all users"),
_l("Post to all users"),
)
action_send_preview = wtforms.SubmitField(
_("Send preview to yourself"),
_l("Send preview to yourself"),
)
@@ -686,6 +831,11 @@ async def system() -> typing.Union[str, werkzeug.Response]:
except KeyError:
pass
try:
metrics["users"] = prosody_metrics["users"]
except KeyError:
pass
for k in list(metrics.keys()):
if metrics[k] is None:
# so that defaulting in jinja works

View File

@@ -4,6 +4,8 @@ import math
import secrets
import typing
from datetime import datetime, timedelta, timezone
import quart.flask_patch # noqa:F401
from quart import (
current_app,
@@ -13,7 +15,8 @@ from quart import (
import flask_babel
import flask_wtf
from flask_babel import _
from flask_babel import lazy_gettext as _l
import flask_babel as _
from . import prosodyclient
@@ -50,7 +53,7 @@ def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
def circle_name(c: typing.Any) -> str:
if c.id_ == "default" and c.name == "default":
return _("Main")
return _l("Main")
return c.name
@@ -70,6 +73,43 @@ def format_bytes(n: float) -> str:
return "{}{}".format(n, unit)
def format_last_activity(timestamp: typing.Optional[int]) -> str:
if timestamp is None:
return _l("Never")
last_active = datetime.fromtimestamp(timestamp, tz=timezone.utc)
# TODO: This 'now' should use the user's local time zone, but we
# don't have that information. Thus 'today'/'yesterday' may be
# slightly inaccurate, but compared to alternative solutions it
# should hopefully be "good enough".
now = datetime.now(tz=timezone.utc)
time_ago = now - last_active
yesterday = now - timedelta(days=1)
if (
last_active.year == now.year
and last_active.month == now.month
and last_active.day == now.day
):
return _l("Today")
elif (
last_active.year == yesterday.year
and last_active.month == yesterday.month
and last_active.day == yesterday.day
):
return _l("Yesterday")
return _.gettext(
"%(time)s ago",
time=flask_babel.format_timedelta(time_ago, granularity="day"),
)
def template_now() -> typing.Dict[str, typing.Any]:
return dict(now=lambda: datetime.now(timezone.utc))
def add_vary_language_header(resp: quart.Response) -> quart.Response:
if getattr(g, "language_header_accessed", False):
resp.vary.add("Accept-Language")
@@ -86,6 +126,8 @@ def init_templating(app: quart.Quart) -> None:
app.template_filter("format_bytes")(format_bytes)
app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name)
app.template_filter("format_last_activity")(format_last_activity)
app.context_processor(template_now)
app.after_request(add_vary_language_header)

View File

@@ -116,6 +116,10 @@ class RegisterForm(BaseForm):
password = wtforms.PasswordField(
_l("Password"),
validators=[
wtforms.validators.InputRequired(),
wtforms.validators.Length(min=10),
],
)
password_confirm = wtforms.PasswordField(
@@ -184,6 +188,10 @@ async def register(id_: str) -> typing.Union[str, werkzeug.Response]:
class ResetForm(BaseForm):
password = wtforms.PasswordField(
_l("Password"),
validators=[
wtforms.validators.InputRequired(),
wtforms.validators.Length(min=10),
],
)
password_confirm = wtforms.PasswordField(

View File

@@ -173,6 +173,42 @@ async def avatar(from_: str, code: str) -> quart.Response:
return response
@bp.route("/terms")
async def terms() -> Response:
if not current_app.config["TOS_URI"]:
return Response("", 404)
return Response("", status=303, headers={
"Location": current_app.config["TOS_URI"],
})
@bp.route("/privacy")
async def privacy() -> Response:
if not current_app.config["PRIVACY_URI"]:
return Response("", 404)
return Response("", status=303, headers={
"Location": current_app.config["PRIVACY_URI"],
})
# This is linked from the iOS app and about page
@bp.route("/policies/")
async def policies() -> str:
return await render_template(
"policies.html",
)
@bp.route("/.well-known/security.txt")
async def securitytxt() -> Response:
return Response(
await render_template("security.txt"),
mimetype="text/plain;charset=UTF-8",
)
@bp.route("/_health")
async def health() -> Response:
return Response("STATUS OK", content_type="text/plain")

View File

@@ -9,26 +9,29 @@ import types
import typing
import typing_extensions
from datetime import datetime
from datetime import datetime, timezone
import aiohttp
import xml.etree.ElementTree as ET
from quart import (
current_app, _app_ctx_stack, session as http_session, abort, redirect,
current_app, session as http_session, abort, redirect,
url_for,
)
import quart
from flask import g as _app_ctx_stack
import werkzeug.exceptions
from . import xmpputil
from .xmpputil import split_jid
SCOPE_DEFAULT = "prosody:scope:default"
SCOPE_ADMIN = "prosody:scope:admin"
SCOPE_RESTRICTED = "prosody:restricted"
SCOPE_DEFAULT = "prosody:registered"
SCOPE_ADMIN = "prosody:admin"
T = typing.TypeVar("T")
@@ -40,6 +43,52 @@ class TokenInfo:
scopes: typing.Collection[str]
@dataclasses.dataclass(frozen=True)
class UserDeletionRequestInfo:
deleted_at: datetime
pending_until: datetime
@classmethod
def from_api_response(
cls,
data: typing.Optional[typing.Mapping[str, typing.Any]],
) -> typing.Optional["UserDeletionRequestInfo"]:
if data is None:
return None
return cls(
deleted_at=datetime.fromtimestamp(
data["deleted_at"],
tz=timezone.utc
),
pending_until=datetime.fromtimestamp(
data["pending_until"],
tz=timezone.utc
)
)
@dataclasses.dataclass(frozen=True)
class AvatarMetadata:
bytes: int
hash: str
type: str
width: typing.Optional[int]
height: typing.Optional[int]
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AvatarMetadata":
return cls(
hash=data["hash"],
bytes=data["bytes"],
type=data["type"],
width=data.get("width") or None,
height=data.get("height") or None,
)
@dataclasses.dataclass(frozen=True)
class AdminUserInfo:
localpart: str
@@ -47,6 +96,10 @@ class AdminUserInfo:
email: typing.Optional[str]
phone: typing.Optional[str]
roles: typing.Optional[typing.List[str]]
enabled: bool
last_active: typing.Optional[int]
deletion_request: typing.Optional[UserDeletionRequestInfo]
avatar_info: typing.List[AvatarMetadata]
@property
def has_admin_role(self) -> bool:
@@ -61,12 +114,32 @@ class AdminUserInfo:
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminUserInfo":
try:
roles: typing.Optional[typing.List[str]] = [data["role"]]
assert roles is not None # make mypy happy
roles.extend(data.get("secondary_roles", []))
except KeyError:
roles = data.get("roles")
avatar_info: typing.List[AvatarMetadata] = []
for avatar in data.get("avatar_info", []):
# Ignore somehow broken avatars.
try:
avatar_metadata = AvatarMetadata.from_api_response(avatar)
avatar_info.append(avatar_metadata)
except KeyError:
pass
return cls(
localpart=data["username"],
display_name=data.get("display_name") or None,
email=data.get("email") or None,
phone=data.get("phone") or None,
roles=data.get("roles"),
roles=roles,
enabled=data.get("enabled", True),
last_active=data.get("last_active") or None,
deletion_request=UserDeletionRequestInfo.from_api_response(
data.get("deletion_request")
),
avatar_info=avatar_info,
)
@@ -87,7 +160,9 @@ class AdminInviteInfo:
expires: datetime
reusable: bool
group_ids: typing.Collection[str]
role_names: typing.Collection[str]
is_reset: bool
note: typing.Optional[str]
@classmethod
def from_api_response(
@@ -104,8 +179,28 @@ class AdminInviteInfo:
xmpp_uri=data.get("xmpp_uri"),
landing_page=data.get("landing_page"),
group_ids=data.get("groups", []),
role_names=data.get("roles", []),
reusable=data["reusable"],
is_reset=data.get("reset", False),
note=data.get("note"),
)
@dataclasses.dataclass(frozen=True)
class AdminGroupChatInfo:
id_: str
jid: str
name: str
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminGroupChatInfo":
return cls(
id_=data["id"],
jid=data["jid"],
name=data.get("name", ""),
)
@@ -113,8 +208,8 @@ class AdminInviteInfo:
class AdminGroupInfo:
id_: str
name: str
muc_jid: typing.Optional[str]
members: typing.Collection[str]
chats: typing.Collection[AdminGroupChatInfo]
@classmethod
def from_api_response(
@@ -124,8 +219,11 @@ class AdminGroupInfo:
return cls(
id_=data["id"],
name=data["name"],
muc_jid=data.get("muc_jid") or None,
members=data.get("members", []),
chats=[
AdminGroupChatInfo.from_api_response(x)
for x in data.get("chats", [])
]
)
@@ -160,7 +258,7 @@ class HTTPSessionManager:
})
async def teardown(self, exc: typing.Optional[BaseException]) -> None:
app_ctx = _app_ctx_stack.top
app_ctx = _app_ctx_stack
try:
session = getattr(app_ctx, self._app_context_attribute)
except AttributeError:
@@ -177,7 +275,7 @@ class HTTPSessionManager:
await session.__aexit__(exc_type, exc, traceback)
async def __aenter__(self) -> aiohttp.ClientSession:
app_ctx = _app_ctx_stack.top
app_ctx = _app_ctx_stack
try:
return getattr(app_ctx, self._app_context_attribute)
except AttributeError:
@@ -311,7 +409,7 @@ class ProsodyClient:
request.add_field("password", password)
request.add_field(
"scope",
" ".join([SCOPE_DEFAULT, SCOPE_ADMIN])
" ".join([SCOPE_RESTRICTED, SCOPE_DEFAULT, SCOPE_ADMIN])
)
self.logger.debug("sending OAuth2 request (payload omitted)")
@@ -820,7 +918,7 @@ class ProsodyClient:
self.session_address,
current_password,
)
await self._xml_iq_call(
password_changed = await self._xml_iq_call(
session,
xmpputil.make_password_change_request(
self.session_address,
@@ -831,7 +929,7 @@ class ProsodyClient:
},
sensitive=True,
)
# TODO: error handling
xmpputil.extract_iq_reply(password_changed)
# TODO: obtain a new token using the new password to allow the
# server to expire/revoke all tokens on password change.
self._store_token_in_session(token_info)
@@ -879,7 +977,7 @@ class ProsodyClient:
localpart: str,
*,
display_name: typing.Optional[str],
roles: typing.Optional[typing.Collection[str]],
role: typing.Optional[str],
session: aiohttp.ClientSession,
) -> None:
payload: typing.Dict[str, typing.Any] = {
@@ -887,8 +985,8 @@ class ProsodyClient:
}
if display_name is not None:
payload["display_name"] = display_name
if roles is not None:
payload["roles"] = list(roles)
if role is not None:
payload["role"] = role
async with session.put(
self._admin_v1_endpoint("/users/{}".format(localpart)),
@@ -896,6 +994,36 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def enable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": True,
},
) as resp:
self._raise_error_from_response(resp)
@autosession
async def disable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": False,
},
) as resp:
self._raise_error_from_response(resp)
@autosession
async def get_user_debug_info(
self,
@@ -962,16 +1090,21 @@ class ProsodyClient:
self,
*,
group_ids: typing.Collection[str] = [],
role_names: typing.Collection[str] = [],
restrict_username: typing.Optional[str] = None,
ttl: typing.Optional[int] = None,
note: typing.Optional[str] = None,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
payload: typing.Dict[str, typing.Any] = {}
payload["groups"] = list(group_ids)
payload["roles"] = list(role_names)
if restrict_username is not None:
payload["username"] = restrict_username
if ttl is not None:
payload["ttl"] = ttl
if note is not None:
payload["note"] = note
async with session.post(
self._admin_v1_endpoint("/invites/account"),
@@ -984,14 +1117,19 @@ class ProsodyClient:
self,
*,
group_ids: typing.Collection[str] = [],
role_names: typing.Collection[str] = [],
ttl: typing.Optional[int] = None,
note: typing.Optional[str] = None,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
payload: typing.Dict[str, typing.Any] = {
"groups": list(group_ids),
"roles": list(role_names),
}
if ttl is not None:
payload["ttl"] = ttl
if note is not None:
payload["note"] = note
async with session.post(
self._admin_v1_endpoint("/invites/group"),
@@ -1024,7 +1162,7 @@ class ProsodyClient:
self,
name: str,
*,
create_muc: bool = True,
create_muc: bool = False,
session: aiohttp.ClientSession,
) -> AdminGroupInfo:
payload = {
@@ -1099,6 +1237,27 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def add_group_chat(
self,
id_: str,
name: str,
*,
session: aiohttp.ClientSession,
) -> None:
payload: typing.Dict[str, typing.Any] = {
"name": name,
}
async with session.post(
self._admin_v1_endpoint(
"/groups/{}/chats".format(id_)
),
json=payload,
) as resp:
self._raise_error_from_response(resp)
@autosession
async def remove_group_member(
self,
@@ -1114,6 +1273,21 @@ class ProsodyClient:
) as resp:
self._raise_error_from_response(resp)
@autosession
async def remove_group_chat(
self,
group_id: str,
chat_id: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint(
"/groups/{}/chats/{}".format(group_id, chat_id)
),
) as resp:
self._raise_error_from_response(resp)
@autosession
async def delete_group(
self,
@@ -1154,7 +1328,6 @@ class ProsodyClient:
self._raise_error_from_response(resp)
return True
@autosession
async def revoke_token(
self,
*,
@@ -1168,7 +1341,8 @@ class ProsodyClient:
async def logout(self) -> None:
try:
await self.revoke_token()
async with self._plain_session as session:
await self.revoke_token(session=session)
except aiohttp.ClientError:
self.logger.warn("failed to revoke token!",
exc_info=True)

View File

@@ -259,6 +259,13 @@ div.form.layout-expanded {
margin: 0;
}
fieldset.descriptive-radio-selection {
p {
margin-top: 0;
margin-bottom: $w-s2;
}
}
input[type="radio"] + label, input[type="checkbox"] + label {
font-weight: inherit;
color: inherit;
@@ -275,22 +282,22 @@ div.form.layout-expanded {
}
@each $type in $text-entry-inputs {
input[type=$type] {
input[type=#{$type}] {
width: 100%;
border: none;
border-bottom: $w-s4 solid $primary-500;
margin-bottom: -$w-s4;
}
input[type=$type].has-error {
input[type=#{$type}].has-error {
border-right: $w-s4 solid $alert-500;
}
input[type=$type]:hover {
input[type=#{$type}]:hover {
border-bottom-color: $primary-700;
}
input[type=$type]:focus {
input[type=#{$type}]:focus {
border-bottom-color: $primary-800;
}
}
@@ -363,6 +370,10 @@ div.form.layout-expanded {
margin-left: 0.25em;
}
.radio-button-ext {
margin-left: 0.5rem;
}
div.select-wrap {
display: block;
border-bottom: $w-s4 solid $primary-500;
@@ -646,69 +657,6 @@ input[type="submit"], button, .button {
/* button, .button {
margin: 0 $w-s2;
}
button.lv-primary, .button.lv-primary {
background-color: $gray-500;
color: $gray-900;
border-radius: $w-s4;
border: $w-s4 solid $gray-400;
@each $type, $values in $colours {
&.c-#{$type} {
border-color: nth($values, 4);
background-color: nth($values, 5);
color: nth($values, 9);
}
&.c-#{$type}:hover {
background-color: nth($values, 4);
}
}
}
button.lv-secondary, .button.lv-secondary {
background-color: $gray-700;
color: $gray-100;
border-radius: $w-s4;
@each $type, $values in $colours {
&.c-#{$type} {
background-color: nth($values, 7);
color: nth($values, 1);
}
}
}
button.lv-tertiary, .button.lv-tertiary {
background-color: inherit;
color: $gray-300;
border-radius: $w-s4;
text-decoration: underline;
@each $type, $values in $colours {
&.c-#{$type} {
color: nth($values, 3);
}
}
}
*/
/*
button.lv-secondary.c-#{$type}, .button.lv-secondary.c-#{$type} {
background-color: nth($values, 7);
color: nth($values, 1);
}
button.lv-tertiary.c-#{$type}, .button.lv-tertiary.c-#{$type} {
color: nth($values, 3);
text-decoration: underline;
background-color: transparent;
}
}*/
/* boxes */
.box {
@@ -771,8 +719,7 @@ button.lv-tertiary, .button.lv-tertiary {
height: 1.5em;
vertical-align: middle;
background-size: cover;
box-shadow: inset 0px 0px 0px 2px rgba(0, 0, 0, 0.2);
border-radius: $w-s4;
border-radius: 10%;
margin: 0 0.25em;
@@ -1045,19 +992,18 @@ div.profile-card {
}
}
/* clipboard button */
/* clipboard and share buttons */
.copy-to-clipboard {
.copy-to-clipboard, .share-button {
cursor: pointer;
font-style: normal;
text-decoration: none;
}
body.no-copy .copy-to-clipboard {
body.no-copy .copy-to-clipboard, body.no-share .share-button {
display: none !important;
}
/* magic */
pre.guru-meditation {
@@ -1121,7 +1067,7 @@ pre.guru-meditation {
}
@each $type in $text-entry-inputs {
input[type=$type] {
input[type=#{$type}] {
background-color: black;
}
@@ -1131,6 +1077,10 @@ pre.guru-meditation {
}
}
label, legend {
color: $gray-800 !important;
}
.box {
background-color: black;
border-color: $gray-800;
@@ -1265,6 +1215,13 @@ pre.guru-meditation {
p.form-desc.weak, p.field-desc.weak {
color: $gray-700;
}
.user-badge-icon {
color: $gray-900 !important;
background-color: $gray-100 !important;
border-color: $gray-300 !important;
box-shadow: black 0 0 2px !important;
}
}
/* tooltip magic */
@@ -1315,3 +1272,53 @@ pre.guru-meditation {
.with-tooltip:hover:before, .with-tooltip:hover:after {
display: block;
}
.username-with-avatar {
display: flex;
align-items: center;
.avatar-container {
position: relative;
.avatar {
margin-left: 0;
}
}
.user-badge-icon {
position: absolute;
bottom: -10px;
right: 0px;
background: white;
border-radius: 50%;
width: 1.2em;
height: 1.2em;
border-color: $gray-500;
border-width: 1px;
border-style: solid;
text-align: center;
margin: 0;
padding: 0;
margin: 0;
padding: 0;
box-shadow: $gray-500 0px 0px 2px;
line-height: 1;
.icon {
/* vertical-align: text-bottom; */
padding: 0.1em;
}
}
.user-info-container {
margin-left: 0.5em;
}
.user-display-name {
font-size: 110%;
}
.user-jid {
font-size: 90%;
}
}

View File

@@ -42,6 +42,16 @@ licensed under the terms of the Apache 2.0 License -->
<g fill="none"><path d="M0 0h24v24H0V0z" /><path d="M0 0h24v24H0V0z" opacity=".87" /></g>
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z" />
</symbol>
<!-- from: action/lock_open/materialiconsround/24px.svg -->
<symbol id="icon-lock_open" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 13c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6-5h-1V6c0-2.76-2.24-5-5-5-2.28 0-4.27 1.54-4.84 3.75-.14.54.18 1.08.72 1.22.53.14 1.08-.18 1.22-.72C9.44 3.93 10.63 3 12 3c1.65 0 3 1.35 3 3v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 11c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-8c0-.55.45-1 1-1h10c.55 0 1 .45 1 1v8z" />
</symbol>
<!-- from: action/restore_from_trash/materialiconsround/24px.svg -->
<symbol id="icon-restore_from_trash" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2H8c-1.1 0-2 .9-2 2v10zm5.65-8.65c.2-.2.51-.2.71 0L16 14h-2v4h-4v-4H8l3.65-3.65zM15.5 4l-.71-.71c-.18-.18-.44-.29-.7-.29H9.91c-.26 0-.52.11-.7.29L8.5 4H6c-.55 0-1 .45-1 1s.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1h-2.5z" />
</symbol>
<!-- from: communication/import_export/materialiconsround/24px.svg -->
<symbol id="icon-import_export" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
@@ -138,6 +148,11 @@ licensed under the terms of the Apache 2.0 License -->
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V18c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-1.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05.02.01.03.03.04.04 1.14.83 1.93 1.94 1.93 3.41V18c0 .35-.07.69-.18 1H22c.55 0 1-.45 1-1v-1.5c0-2.33-4.67-3.5-7-3.5z" />
</symbol>
<!-- from: social/person/materialiconsround/24px.svg -->
<symbol id="icon-person" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v1c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-1c0-2.66-5.33-4-8-4z" />
</symbol>
<!-- from: social/group_add/materialiconsround/24px.svg -->
<symbol id="icon-create_group" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
@@ -178,4 +193,9 @@ licensed under the terms of the Apache 2.0 License -->
<g><rect fill="none" height="24" width="24" /><rect fill="none" height="24" width="24" /></g>
<g><g><path d="M21,8c-1.45,0-2.26,1.44-1.93,2.51l-3.55,3.56c-0.3-0.09-0.74-0.09-1.04,0l-2.55-2.55C12.27,10.45,11.46,9,10,9 c-1.45,0-2.27,1.44-1.93,2.52l-4.56,4.55C2.44,15.74,1,16.55,1,18c0,1.1,0.9,2,2,2c1.45,0,2.26-1.44,1.93-2.51l4.55-4.56 c0.3,0.09,0.74,0.09,1.04,0l2.55,2.55C12.73,16.55,13.54,18,15,18c1.45,0,2.27-1.44,1.93-2.52l3.56-3.55 C21.56,12.26,23,11.45,23,10C23,8.9,22.1,8,21,8z" /><polygon points="15,9 15.94,6.93 18,6 15.94,5.07 15,3 14.08,5.07 12,6 14.08,6.93" /><polygon points="3.5,11 4,9 6,8.5 4,8 3.5,6 3,8 1,8.5 3,9" /></g></g>
</symbol>
<!-- from: social/share/materialiconsround/24px.svg -->
<symbol id="icon-share" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z" />
</symbol>
</defs></svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -6,16 +6,20 @@
{% block body %}
<main>
<div class="box el-2">
<h1>{% trans %}About Snikket{% endtrans %}</h1>
<p>{% trans snikket_url="https://snikket.org" %}To learn more about Snikket, visit the <a href="{{ snikket_url}}">Snikket website</a>.{% endtrans %}</p>
<h2>{% trans %}About this Service{% endtrans %}</h2>
<p>{% trans site_name=config["SITE_NAME"] %}This is the Snikket service <em>{{ site_name }}</em>.{% endtrans %}</p>
<p>{% trans site_name=config["SITE_NAME"] %}This is the Snikket service <em>{{ site_name }}</em>, running open-source software from the Snikket project.{% endtrans %}</p>
<p>{% trans snikket_url="https://snikket.org" %}To learn more about Snikket, visit the <a href="{{ snikket_url}}">Snikket website</a>.{% endtrans %}</p>
<p><a href="/policies/">{% trans %}View service policies{% endtrans %}</a>
<h3>{% trans %}Licenses{% endtrans %}</h3>
<p>{% trans agpl_url="https://www.gnu.org/licenses/agpl.html" %}The web portal software is licensed under the terms of the <a href="{{ agpl_url }}">Affero GNU General Public License, version 3.0 or later</a>. The full terms of the license can be reviewed using the aforementioned link.{% endtrans %}</p>
<p>{% trans source_url="https://github.com/snikket-im/snikket-web-portal/" %}The source code of the web portal can be downloaded and viewed in <a href="{{ source_url }}">its GitHub repository</a>.{% endtrans %}</p>
<p>{% trans source_url="https://material.io/resources/icons/", apache20_url="https://www.apache.org/licenses/LICENSE-2.0.txt" %}The icons used in the web portal are <a href="{{ source_url }}">Googles Material Icons</a>, made available by Google under the terms of the <a href="{{ apache20_url }}">Apache 2.0 License</a>.{% endtrans %}</p>
<h3>{% trans %}Trademarks{% endtrans %}</h3>
<p>{% trans trademarks_url="https://snikket.org/about/trademarks/" %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company. For more information about the trademarks, visit the <a href="{{ trademarks_url }}">Snikket Trademarks information page</a>.{% endtrans %}
<h3>{% trans %}Software Versions{% endtrans %}</h3>
<pre>Domain: {{ config["SNIKKET_DOMAIN"] }}
Web Portal{% if version %} ({{ version }}){% endif %}
@@ -27,6 +31,7 @@ Web Portal{% if version %} ({{ version }}){% endif %}
{% for name, version in extra_versions.items() %}
{{ name }} ({{ version }}){% endfor %}
{%- endif -%}</pre>
<p>
{%- call standard_button("back", url_for("index"), class="primary") -%}
{% trans %}Back to the main page{% endtrans %}

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,57 @@
{% from "library.j2" import form_button, render_errors %}
{% from "library.j2" import form_button,
render_errors,
access_level_description, access_level_icon,
invite_type_description, invite_type_icon
%}
<form method="POST" action="{{ url_for(".create_invite") }}">
{{- invite_form.csrf_token -}}
<div class="form layout-expanded">
<h2 class="form-title">{% trans %}Create new invitation{% endtrans %}</h2>
<p class="form-descr weak">{% trans %}Create a new invitation link to invite more users to your Snikket service by clicking the button below.{% endtrans %}</p>
<!-- Invitation type -->
<div class="f-ebox">
<fieldset>{#- -#}
<fieldset class="descriptive-radio-selection">{#- -#}
<legend>{{ invite_form.type_.label.text }}</legend>
{{- invite_form.type_ -}}
<p>{% trans %}Choose whether this invitation link will allow more than one person to join.{% endtrans %}</p>
{%- for invite_type in invite_form.type_ -%}
<div class="radio-button-ext">
{{ invite_type }}<label for="{{ invite_type.id }}">
{%- trans title=invite_type.label.text, icon=invite_type_icon(invite_type.data), description=invite_type_description(invite_type.data) -%}
<span class="invite-type">{{ title }}{{ icon }}</span><p>{{ description }}</p>
{%- endtrans -%}
</label>
</div>
{%- endfor -%}
</fieldset>
</div>
<!-- Access level -->
<div class="f-ebox">
<fieldset class="descriptive-radio-selection">{#- -#}
<legend>{{ invite_form.role.label.text }}</legend>
<p>{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}</p>
{%- for level in invite_form.role -%}
<div class="radio-button-ext">
{{ level }}<label for="{{ level.id }}">
{%- trans title=level.label.text, icon=access_level_icon(level.data), description=access_level_description(level.data) -%}
<span class="access-level">{{ title }}{{ icon }}</span><p>{{ description }}</p>
{%- endtrans -%}
</label>
</div>
{%- endfor -%}
</fieldset>
</div>
<!-- Valid for -->
<div class="f-ebox">
{{ invite_form.lifetime.label }}
<div class="select-wrap">{{ invite_form.lifetime }}</div>
</div>
<!-- Invite to circle -->
<div class="f-ebox">
{#
NOTE: This is for when/if we ever support multi-group invites.
@@ -27,6 +65,13 @@
<div class="select-wrap">{{ invite_form.circles }}</div>
{%- call render_errors(invite_form.circles) -%}{%- endcall -%}
</div>
<!-- Comment -->
<div class="f-ebox">
{{ invite_form.note.label }}
{{ invite_form.note }}
</div>
<div class="f-bbox">
{%- call form_button("create_link", invite_form.action_create_invite, class="primary") %}{% endcall -%}
</div>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{% extends "admin_app.html" %}
{% from "library.j2" import showuri, form_button, standard_button, extract_circle_name, invite_type_description %}
{% from "library.j2" import showuri, form_button, standard_button, extract_circle_name, invite_type_name, invite_type_description %}
{% block head_lead %}
{{ super() }}
{% include "copy-snippet.html" %}
@@ -13,9 +13,10 @@
<dt>{% trans %}Valid until{% endtrans %}</dt>
<dd>{{ invite.expires | format_date }}</dd>
<dt><label for="link-field">{% trans %}Link{% endtrans %}</label></dt>
<dd>{% call showuri(invite.landing_page, id_="link-field") %}{% endcall %}</dd>
<dd>{% call showuri(invite.landing_page, id_="link-field") %}{% trans %}Invitation to Snikket{% endtrans %}{% endcall %}</dd>
<dt>{% trans %}Invitation type{% endtrans %}</dt>
<dd>{% call invite_type_description(invite) %}{% endcall %}</dd>
{% set invite_type = invite.reusable and "group" or "account" %}
<dd><span class="with-tooltip above" data-tooltip="{% call invite_type_description(invite_type) %}{% endcall %}">{% call invite_type_name(invite_type) %}{% endcall %}</span></dd>
{%- set ngroups = invite.group_ids | length -%}
{%- if ngroups > 1 -%}
{#- not supported via the web UI, but we should still display it properly -#}

View File

@@ -1,30 +1,35 @@
{% 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 %}
{% from "library.j2" import box, form_button, standard_button, icon, access_level_description, access_level_icon %}
{% block content %}
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
<form method="POST">{{ form.csrf_token }}<div class="form layout-expanded">
{% if target_user.deletion_request %}
<div class="box alert">
<header>{% trans %}This user account is pending deletion{% endtrans %}</header>
<p>{% trans date=target_user.deletion_request.deleted_at | format_datetime %}The owner of the account sent a deletion request on {{ date }} using their app.{% endtrans %}
<p>{% trans time=(target_user.deletion_request.pending_until - now())|format_timedelta %}The account has been locked, and will be automatically deleted permanently in {{ time }}.{% endtrans %}</p>
<p>{% trans %}If this was a mistake, you can cancel the deletion and restore the account.{% endtrans %}</p>
{%- call form_button("restore_from_trash", form.action_restore, class="secondary") %}{% endcall %}
</div>
{% elif not target_user.enabled %}
<div class="box alert">
<header>{% trans %}This user account is locked{% endtrans %}</header>
<p>{% trans %}The user will not be able to log in to their account until it is unlocked again.{% endtrans %}</p>
{%- call form_button("lock_open", form.action_enable, class="secondary") %}{% endcall %}
</div>
{% endif %}
<h2 class="form-title">{% trans %}Edit user{% endtrans %}</h2>
<div class="f-ebox">
{{ form.localpart.label }}
{{ form.localpart(readonly="readonly") }}
<p class="form-desc weak">{% trans %}The login name cannot be changed.{% endtrans %}</p>
</div>
<div class="f-ebox">
{{ form.display_name.label }}
{{ form.display_name }}
@@ -63,14 +68,14 @@
{% trans %}If the user has lost their password, you can use the button below to create a special link which allows to change the password of the account, once.{% endtrans %}
</p>
<div class="f-bbox">
{%- call form_button("passwd", form.action_create_reset, class="primary") -%}{%- endcall -%}
{%- call form_button("passwd", form.action_create_reset, class="secondary") -%}{%- 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") -%}
{%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="secondary") -%}
{%- trans -%}Show debug information{%- endtrans -%}
{%- endcall -%}
</div>

View File

@@ -1,5 +1,5 @@
{% extends "admin_app.html" %}
{% from "library.j2" import action_button, icon, clipboard_button, form_button, custom_form_button, extract_circle_name, invite_type_name, invite_type_description %}
{% from "library.j2" import action_button, icon, clipboard_button, share_button, form_button, custom_form_button, extract_circle_name, invite_type_name, invite_type_description %}
{% block head_lead %}
{{ super() }}
{% include "copy-snippet.html" %}
@@ -18,17 +18,18 @@
<col/>
<thead>
<tr>
<th>{% trans %}Expires{% endtrans %}</th>
<th class="collapsible">{% trans %}Type{% endtrans %}</th>
<th class="collapsible">{% trans %}Circle{% endtrans %}</th>
<th>{% trans %}Expires{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for invite in invites %}
{% set invite_type = invite.reusable and "group" or "account" %}
<tr>
<td>{{ (invite.expires - now) | format_timedelta(add_direction=True) }}</td>
<td class="collapsible"><span class="with-tooltip above" data-tooltip="{% call invite_type_description(invite) %}{% endcall %}">{% call invite_type_name(invite) %}{% endcall %}</span></td>
<td class="collapsible"><span class="with-tooltip above" data-tooltip="{% call invite_type_description(invite_type) %}{% endcall %}">{% call invite_type_name(invite_type) %}{% endcall %}</span></td>
<td class="collapsible">
{#- -#}
<ul class="inline">
@@ -38,6 +39,8 @@
</ul>
{#- -#}
</td>
<td>{{ (invite.expires - now) | format_timedelta(add_direction=True) }}</td>
<td>{% if invite.note is not none %}{{ invite.note }}{% endif %}</td>
<td class="nowrap">
{%- call action_button("more", url_for(".edit_invite", id_=invite.id_), class="secondary") -%}
{% trans %}Show invite details{% endtrans %}
@@ -45,6 +48,9 @@
{%- call clipboard_button(invite.landing_page, class="primary") -%}
{% trans %}Copy invite link to clipboard{% endtrans %}
{%- endcall -%}
{%- call share_button("Invitation to Snikket", invite.landing_page, class="primary") -%}
{% trans %}Share invitation link{% endtrans %}
{%- endcall -%}
{%- call custom_form_button("remove_link", form.action_revoke.name, invite.id_, class="secondary danger", slim=True) -%}
{% trans %}Delete invitation{% endtrans %}
{%- endcall -%}

View File

@@ -15,7 +15,7 @@
<dt>{% trans %}Valid until{% endtrans %}</dt>
<dd>{{ reset_link.expires | format_date }}</dd>
<dt><label for="link-field">{% trans %}Link{% endtrans %}</label></dt>
<dd>{% call showuri(reset_link.landing_page, id_="link-field") %}{% endcall %}</dd>
<dd>{% call showuri(reset_link.landing_page, id_="link-field") %}Reset your Snikket password{% endcall %}</dd>
</dd>
<div class="f-bbox">
{%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%}

View File

@@ -76,13 +76,20 @@
<em>{% trans %}unknown{% endtrans %}</em>
{%- endif -%}
</dd>
<dt>{% trans %}Connected devices{% endtrans %}</dt>
<dt>{% trans %}Active users{% endtrans %}</dt>
<dd>
<ul>
{%- if metrics.prosody_devices | default(None) is not none -%}
{{ metrics.prosody_devices }}
<li>{% trans %}Connected now:{% endtrans %} {{ metrics.prosody_devices }}</li>
{%- else -%}
<em>{% trans %}unknown{% endtrans %}</em>
<li><em>{% trans %}unknown{% endtrans %}</em></li>
{%- endif -%}
{%- if metrics.users | default(None) is not none -%}
<li>{% trans %}Past 24 hours:{% endtrans %} {{ metrics.users.active_1d }}</li>
<li>{% trans %}Past 7 days:{% endtrans %} {{ metrics.users.active_7d }}</li>
<li>{% trans %}Past 30 days:{% endtrans %} {{ metrics.users.active_30d }}</li>
{%- endif -%}
</ul>
</dd>
</dl>
</div>

View File

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

View File

@@ -16,5 +16,5 @@
<meta name="msapplication-TileColor" content="#fbd308">
<meta name="theme-color" content="#fbd308">
</head>
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %} class="{% if is_in_debug_mode %}debug{% endif %}{% if body_class | default(False) %} {{ body_class }}{% endif %}"{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
<body{% if body_id | default(False) %} id="{{ body_id }}"{% endif %} class="{% if is_in_debug_mode %}debug{% endif %}{% if body_class | default(False) %} {{ body_class }}{% endif %} no-copy no-share"{% if onload | default(False) %} onload="{{ onload }}"{% endif %}>{% block body %}{% endblock %}</body>
</html>

View File

@@ -115,8 +115,63 @@ var copy_to_clipboard_btn = function(el) {
});
};
var copy_to_clipboard_btn = function(el) {
var text = el.dataset.cliptext;
if (!text) {
console.error('copy_to_clipboard used on element without text to copy');
}
copyTextToClipboard(text, el, function(success) {
var existing_result_el = document.getElementById("clipboard-result");
if (existing_result_el !== null) {
existing_result_el.parentNode.removeChild(existing_result_el);
}
var icon = "done";
if (!success) {
icon = "cancel";
}
var icon_bak = get_current_icon(el.firstChild);
change_icon(el.firstChild, icon);
setTimeout(function() {
change_icon(el.firstChild, icon_bak);
el.blur();
}, 1500);
});
};
var share_url_btn = function(el) {
let data = {
"title": el.dataset.shareTitle,
"url": el.dataset.shareUrl,
}
let icon_bak = get_current_icon(el.firstChild);
new Promise(function (resolve, reject) {
if(!navigator.canShare || !navigator.canShare(data)) {
return reject();
}
return resolve(navigator.share(data));
}).then(function () {
// Success
change_icon(el.firstChild, "done");
}, function () {
// Failure
change_icon(el.firstChild, "cancel");
}).finally(function () {
// Either way, clear status icon after 1.5s
setTimeout(function() {
change_icon(el.firstChild, icon_bak);
el.blur();
}, 1500);
});
}
window.addEventListener('load', function() {
document.body.classList.remove("no-copy");
if(navigator.share) {
document.body.classList.remove("no-share");
}
});
</script>

View File

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

View File

@@ -17,6 +17,13 @@
{%- else -%}
<p>{% trans site_name=config["SITE_NAME"] %}You have been invited to chat on {{ site_name }} using Snikket, a secure, privacy-friendly chat app.{% endtrans %}</p>
{%- endif -%}
{%- if config["TOS_URI"] and config["PRIVACY_URI"] -%}
<p>
{% trans site_name=config["SITE_NAME"], tos_uri=config["TOS_URI"], privacy_uri=config["PRIVACY_URI"] %}By continuing, you agree to the <a href="{{tos_uri}}">Terms of Service</a> and <a href="{{privacy_uri}}">Privacy Policy</a>.{% endtrans %}
</p>
{%- endif -%}
<h2>{% trans %}Get started{% endtrans %}</h2>
{%- if apple_store_url -%}
<p>{% trans %}Install the Snikket App on your Android or iOS device.{% endtrans %}</p>
@@ -127,7 +134,6 @@
var onload = function() {
apply_qr_code(document.getElementById("qr-invite-page"));
apply_qr_code(document.getElementById("qr-uri"));
var popover_as = document.getElementsByClassName("popover");
for (var i = 0; i < popover_as.length; ++i) {
var a = popover_as[i];

View File

@@ -10,12 +10,38 @@
{%- endif -%}
{%- endmacro %}
{% macro render_user(user, caller=None) -%}
<div class="username-with-avatar">
<div class="avatar-container">
{%- call avatar(user.localpart+"@"+config["SNIKKET_DOMAIN"], user.avatar_info[0].hash if user.avatar_info | length > 0 else None ) %}{% endcall -%}
{%- if user.has_admin_role -%}
<div class="user-badge-icon">
<span class="with-tooltip above" data-tooltip="{% trans %}The user is an administrator.{% endtrans %}">{% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %}</span>
</div>
{%- elif user.has_restricted_role -%}
<div class="user-badge-icon">
<span class="with-tooltip above" data-tooltip="{% trans %}The user is restricted.{% endtrans %}">{% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %}</span>
</div>
{%- endif -%}
</div>
<div class="user-info-container">
{%- if user.display_name %}
<div class="user-display-name">{{- user.display_name -}}</div>
{%- endif %}
<div class="user-jid"><span class="user-jid-localpart">{{- user.localpart -}}</span><span class="user-jid-at">@</span><span class="user-jid-domain">{{- config["SNIKKET_DOMAIN"] -}}</span></div>
</div>
</div>
{%- endmacro -%}
{% macro showuri(uri, caller=None, id_=None) %}
{%- if uri is none -%}
<em>—</em>
{%- else -%}
<div><input type="text" {% if id_ %}id="{{ id_ }}" {% endif %}readonly="readonly" value="{{ uri }}"></div>
<div>{% call clipboard_button(uri, show_label=True) %}{% trans %}Copy link{% endtrans %}{% endcall %}</div>
<div>
{% call clipboard_button(uri, show_label=True) %}{% trans %}Copy link{% endtrans %}{% endcall %}
{% call share_button(caller() if caller is not none else None, uri, show_label=True) %}{% trans %}Share{% endtrans %}{% endcall %}
</div>
{%- endif -%}
{% endmacro %}
@@ -59,7 +85,7 @@
{% macro clipboard_button(data, show_label=False, caller=None, class=None) -%}
{%- set label = caller() -%}
<a class="button{% if class %} {{ class }}{% endif %}"
<a class="button copy-to-clipboard{% if class %} {{ class }}{% endif %}"
href="#"
{% if not show_label %}
aria-label="{{ label }}"
@@ -74,6 +100,24 @@
</a>
{%- endmacro %}
{% macro share_button(title, url, show_label=False, caller=None, class=None) -%}
{%- set label = caller() -%}
<a class="button share-button{% if class %} {{ class }}{% endif %}"
href="#"
{% if not show_label %}
aria-label="{{ label }}"
title="{{ label }}"
{% endif %}
data-share-title="{{ title }}"
data-share-url="{{ url }}"
onclick="share_url_btn(this); return false;">
{%- call icon("share") %}{% endcall -%}
{%- if show_label %}
<span>{{ label }}</span>
{% endif -%}
</a>
{%- endmacro %}
{% macro render_errors(field, caller=None) -%}
{%- set error_list = field.errors if field.errors is not mapping else (field.errors.values() | flatten | list) -%}
{%- if error_list -%}
@@ -109,18 +153,44 @@
{%- endif -%}
{% endmacro %}
{%- macro invite_type_name(invite_info, caller=None) -%}
{%- if invite_info.reusable -%}
{% trans %}Group{% endtrans %}
{%- else -%}
{%- macro invite_type_name(invite_type, caller=None) -%}
{%- if invite_type == "account" -%}
{% trans %}Individual{% endtrans %}
{%- else -%}
{% trans %}Group{% endtrans %}
{%- endif -%}
{%- endmacro -%}
{%- macro invite_type_description(invite_info, caller=None) -%}
{%- if invite_info.reusable -%}
{% trans %}Can be used multiple times to create accounts on this Snikket service.{% endtrans %}
{%- else -%}
{% trans %}Can be used once to create an account on this Snikket service.{% endtrans %}
{% macro access_level_description(role, caller=None) %}
{%- if role == "prosody:restricted" -%}
{% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %}
{%- elif role == "prosody:registered" -%}
{% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %}
{%- elif role == "prosody:admin" -%}
{% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %}
{%- endif -%}
{%- endmacro -%}
{% endmacro %}
{% macro access_level_icon(role, caller=None) %}
{%- if role == "prosody:restricted" -%}
{% call icon("lock") %}{% endcall %}
{%- elif role == "prosody:admin" -%}
{% call icon("admin") %}{% endcall %}
{%- endif -%}
{% endmacro %}
{% macro invite_type_description(invite_type, caller=None) %}
{%- if invite_type == "account" -%}
{% trans %}Invite a single person (invitation link can only be used once).{% endtrans %}
{%- elif invite_type == "group" -%}
{% trans %}Invite a group of people (invitation link can be used multiple times).{% endtrans %}
{%- endif -%}
{% endmacro %}
{% macro invite_type_icon(invite_type, caller=None) %}
{%- if invite_type == "account" -%}
{% call icon("person") %}{% endcall %}
{%- elif invite_type == "group" -%}
{% call icon("people") %}{% endcall %}
{%- endif -%}
{% endmacro %}

View File

@@ -30,7 +30,7 @@
<div class="f-bbox">
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
</div>
</from>
</form>
<script type="text/javascript">
var domainCheck = function() {
var form = document.getElementById("login-form");

View File

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

View File

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

View File

@@ -6,8 +6,13 @@
{% include "copy-snippet.html" %}
{% endblock %}
{% block content %}
<h1>{% trans %}Welcome!{% endtrans %}</h1>
<p>{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}</p>
{% if user_info.is_admin and metrics.users and metrics.users.active_1d <= 1 %}
<aside class="box hint">
<header>{% trans %}Welcome to Snikket!{% endtrans %}</header>
<p>{% trans %}Now your Snikket instance is up and running, the next step is to invite people to join it. Family, friends, colleagues... you choose!{% endtrans %}</p>
<a href="/admin/invitations">{% trans %}Create new invitation{% endtrans %}</a>
</aside>
{% endif %}
<nav class="welcome">
<ul>
<li class="wide">

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -32,16 +32,22 @@ class ChangePasswordForm(BaseForm):
new_password = wtforms.PasswordField(
_l("New password"),
validators=[wtforms.validators.InputRequired()]
validators=[
wtforms.validators.InputRequired(),
wtforms.validators.Length(min=10),
]
)
new_password_confirm = wtforms.PasswordField(
_l("Confirm new password"),
validators=[wtforms.validators.InputRequired(),
wtforms.validators.EqualTo(
"new_password",
_l("The new passwords must match.")
)]
validators=[
wtforms.validators.InputRequired(),
wtforms.validators.EqualTo(
"new_password",
_l("The new passwords must match.")
),
wtforms.validators.Length(min=10),
]
)
@@ -91,7 +97,12 @@ class ImportAccountDataForm(BaseForm):
@client.require_session()
async def index() -> str:
user_info = await client.get_user_info()
return await render_template("user_home.html", user_info=user_info)
metrics = await client.get_system_metrics()
return await render_template(
"user_home.html",
user_info=user_info,
metrics=metrics,
)
@bp.route('/passwd', methods=["GET", "POST"])

View File

@@ -6,6 +6,8 @@ action/logout:logout
action/login:login
action/exit_to_app:exit_to_app
action/lock:lock
action/lock_open:lock_open
action/restore_from_trash:restore_from_trash
communication/import_export:import_export
communication/qr_code:qrcode
communication/vpn_key:passwd
@@ -25,6 +27,7 @@ navigation/cancel:cancel
navigation/more_vert:more
social/groups:groups
social/people:people
social/person:person
social/group_add:create_group
social/person_add:add_user
social/person_remove:remove_user
@@ -33,3 +36,4 @@ image/edit:edit
action/admin_panel_settings:admin
content/link:link
content/insights:insights
social/share:share

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

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