Compare commits

...

14 Commits

Author SHA1 Message Date
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
11 changed files with 457 additions and 94 deletions

View File

@@ -86,6 +86,14 @@ class EditUserForm(BaseForm):
_l("Update user"),
)
action_restore = wtforms.SubmitField(
_l("Restore account"),
)
action_enable = wtforms.SubmitField(
_l("Unlock account"),
)
action_create_reset = wtforms.SubmitField(
_l("Create password reset link"),
)
@@ -112,6 +120,32 @@ 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,
@@ -123,7 +157,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
_("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

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

@@ -9,7 +9,7 @@ import types
import typing
import typing_extensions
from datetime import datetime
from datetime import datetime, timezone
import aiohttp
@@ -42,6 +42,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
@@ -49,6 +95,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:
@@ -75,6 +125,15 @@ class AdminUserInfo:
email=data.get("email") or None,
phone=data.get("phone") or None,
roles=roles,
enabled=data.get("enabled", True),
last_active=data.get("last_active") or None,
deletion_request=UserDeletionRequestInfo.from_api_response(
data.get("deletion_request")
),
avatar_info=[
AvatarMetadata.from_api_response(avatar_info)
for avatar_info in data.get("avatar_info", [])
],
)
@@ -925,6 +984,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,

View File

@@ -708,8 +708,7 @@ input[type="submit"], button, .button {
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;
@@ -1068,6 +1067,10 @@ pre.guru-meditation {
}
}
label, legend {
color: $gray-800 !important;
}
.box {
background-color: black;
border-color: $gray-800;
@@ -1202,6 +1205,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 */
@@ -1252,3 +1262,46 @@ 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;
}
}

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" />

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -19,12 +19,33 @@
{% 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 +84,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,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,15 +14,15 @@
{% 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 %}

View File

@@ -10,6 +10,29 @@
{%- 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">
<div class="user-localpart">{{- user.localpart -}}</div>
{%- if user.display_name %}
<div class="user-display-name">{{- user.display_name -}}</div>
{%- endif %}
</div>
</div>
{%- endmacro -%}
{% macro showuri(uri, caller=None, id_=None) %}
{%- if uri is none -%}
<em>—</em>

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-11-06 13:46+0000\n"
"POT-Creation-Date: 2023-12-08 12:08+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -19,17 +19,15 @@ msgstr ""
#: snikket_web/admin.py:69 snikket_web/templates/admin_delete_user.html:10
#: snikket_web/templates/admin_edit_circle.html:73
#: snikket_web/templates/admin_users.html:8
msgid "Login name"
msgstr ""
#: snikket_web/admin.py:73 snikket_web/templates/admin_delete_user.html:12
#: snikket_web/templates/admin_edit_circle.html:74
#: snikket_web/templates/admin_users.html:9 snikket_web/user.py:63
#: snikket_web/templates/admin_edit_circle.html:74 snikket_web/user.py:63
msgid "Display name"
msgstr ""
#: snikket_web/admin.py:77 snikket_web/templates/admin_edit_user.html:32
#: snikket_web/admin.py:77 snikket_web/templates/admin_edit_user.html:53
msgid "Access Level"
msgstr ""
@@ -50,187 +48,228 @@ msgid "Update user"
msgstr ""
#: snikket_web/admin.py:90
msgid "Restore account"
msgstr ""
#: snikket_web/admin.py:94
msgid "Unlock account"
msgstr ""
#: snikket_web/admin.py:98
msgid "Create password reset link"
msgstr ""
#: snikket_web/admin.py:108
#: snikket_web/admin.py:116
msgid "Password reset link created"
msgstr ""
#: snikket_web/admin.py:123
msgid "User information updated."
#: snikket_web/admin.py:128
msgid "User account restored"
msgstr ""
#: snikket_web/admin.py:133
msgid "User account unlocked"
msgstr ""
#: snikket_web/admin.py:140
msgid "Could not restore user account"
msgstr ""
#: snikket_web/admin.py:145
msgid "Could not unlock user account"
msgstr ""
#: snikket_web/admin.py:157
msgid "User information updated."
msgstr ""
#: snikket_web/admin.py:179
msgid "Delete user permanently"
msgstr ""
#: snikket_web/admin.py:158
#: snikket_web/admin.py:192
msgid "User deleted"
msgstr ""
#: snikket_web/admin.py:196
#: snikket_web/admin.py:230
msgid "Password reset link not found"
msgstr ""
#: snikket_web/admin.py:208
#: snikket_web/admin.py:242
msgid "Password reset link deleted"
msgstr ""
#: snikket_web/admin.py:228
#: snikket_web/admin.py:262
msgid "Invite to circle"
msgstr ""
#: snikket_web/admin.py:234
#: snikket_web/admin.py:268
msgid "At least one circle must be selected"
msgstr ""
#: snikket_web/admin.py:239
#: snikket_web/admin.py:273
msgid "Valid for"
msgstr ""
#: snikket_web/admin.py:241
#: snikket_web/admin.py:275
msgid "One hour"
msgstr ""
#: snikket_web/admin.py:242
#: snikket_web/admin.py:276
msgid "Twelve hours"
msgstr ""
#: snikket_web/admin.py:243
#: snikket_web/admin.py:277
msgid "One day"
msgstr ""
#: snikket_web/admin.py:244
#: snikket_web/admin.py:278
msgid "One week"
msgstr ""
#: snikket_web/admin.py:245
#: snikket_web/admin.py:279
msgid "Four weeks"
msgstr ""
#: snikket_web/admin.py:251 snikket_web/templates/admin_edit_invite.html:17
#: snikket_web/admin.py:285 snikket_web/templates/admin_edit_invite.html:17
msgid "Invitation type"
msgstr ""
#: snikket_web/admin.py:253 snikket_web/templates/library.j2:116
#: snikket_web/admin.py:287 snikket_web/templates/library.j2:139
msgid "Individual"
msgstr ""
#: snikket_web/admin.py:254 snikket_web/templates/library.j2:114
#: snikket_web/admin.py:288 snikket_web/templates/library.j2:137
msgid "Group"
msgstr ""
#: snikket_web/admin.py:260
#: snikket_web/admin.py:294
msgid "New invitation link"
msgstr ""
#: snikket_web/admin.py:322
#: snikket_web/admin.py:356
msgid "Revoke"
msgstr ""
#: snikket_web/admin.py:346
#: snikket_web/admin.py:380
msgid "Invitation created"
msgstr ""
#: snikket_web/admin.py:362
#: snikket_web/admin.py:396
msgid "No such invitation exists"
msgstr ""
#: snikket_web/admin.py:377
#: snikket_web/admin.py:411
msgid "Invitation revoked"
msgstr ""
#: snikket_web/admin.py:394 snikket_web/admin.py:442
#: snikket_web/admin.py:428 snikket_web/admin.py:476
#: snikket_web/templates/admin_delete_circle.html:10
#: snikket_web/templates/admin_edit_circle.html:44
msgid "Name"
msgstr ""
#: snikket_web/admin.py:399 snikket_web/templates/admin_circles.html:47
#: snikket_web/admin.py:433 snikket_web/templates/admin_circles.html:47
msgid "Create circle"
msgstr ""
#: snikket_web/admin.py:429
#: snikket_web/admin.py:463
msgid "Circle created"
msgstr ""
#: snikket_web/admin.py:447
#: snikket_web/admin.py:481
msgid "Select user"
msgstr ""
#: snikket_web/admin.py:452
#: snikket_web/admin.py:486
msgid "Update circle"
msgstr ""
#: snikket_web/admin.py:458
#: snikket_web/admin.py:492
msgid "Add user"
msgstr ""
#: snikket_web/admin.py:476 snikket_web/admin.py:575 snikket_web/admin.py:623
#: snikket_web/admin.py:510 snikket_web/admin.py:609 snikket_web/admin.py:657
msgid "No such circle exists"
msgstr ""
#: snikket_web/admin.py:513
#: snikket_web/admin.py:547
msgid "Circle data updated"
msgstr ""
#: snikket_web/admin.py:523
#: snikket_web/admin.py:557
msgid "User added to circle"
msgstr ""
#: snikket_web/admin.py:532
#: snikket_web/admin.py:566
msgid "User removed from circle"
msgstr ""
#: snikket_web/admin.py:541
#: snikket_web/admin.py:575
msgid "Chat removed from circle"
msgstr ""
#: snikket_web/admin.py:559
#: snikket_web/admin.py:593
msgid "Delete circle permanently"
msgstr ""
#: snikket_web/admin.py:586
#: snikket_web/admin.py:620
msgid "Circle deleted"
msgstr ""
#: snikket_web/admin.py:600
#: snikket_web/admin.py:634
msgid "Group chat name"
msgstr ""
#: snikket_web/admin.py:605
#: snikket_web/admin.py:639
msgid "Create group chat"
msgstr ""
#: snikket_web/admin.py:635
#: snikket_web/admin.py:669
msgid "New group chat added to circle"
msgstr ""
#: snikket_web/admin.py:702
#: snikket_web/admin.py:736
msgid "Message contents"
msgstr ""
#: snikket_web/admin.py:708
#: snikket_web/admin.py:742
msgid "Only send to online users"
msgstr ""
#: snikket_web/admin.py:712
#: snikket_web/admin.py:746
msgid "Post to all users"
msgstr ""
#: snikket_web/admin.py:716
#: snikket_web/admin.py:750
msgid "Send preview to yourself"
msgstr ""
#: snikket_web/admin.py:738
#: snikket_web/admin.py:772
msgid "Announcement sent!"
msgstr ""
#: snikket_web/infra.py:53
#: snikket_web/infra.py:56
msgid "Main"
msgstr ""
#: snikket_web/infra.py:78
msgid "Never"
msgstr ""
#: snikket_web/infra.py:95
msgid "Today"
msgstr ""
#: snikket_web/infra.py:101
msgid "Yesterday"
msgstr ""
#: snikket_web/infra.py:105
#, python-format
msgid "%(time)s ago"
msgstr ""
#: snikket_web/invite.py:35
msgid ""
"The account data you tried to import is too large to upload. Please "
@@ -639,7 +678,7 @@ msgid "Delete user %(user_name)s"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:6
#: snikket_web/templates/admin_edit_user.html:53
#: snikket_web/templates/admin_edit_user.html:74
msgid "Delete user"
msgstr ""
@@ -708,7 +747,7 @@ msgid "The user has been deleted from the server."
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:85
#: snikket_web/templates/library.j2:108
#: snikket_web/templates/library.j2:131
msgid "deleted"
msgstr ""
@@ -808,56 +847,90 @@ msgstr ""
msgid "Edit user %(user_name)s"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:22
msgid "Edit user"
#: snikket_web/templates/admin_edit_user.html:24
msgid "This user account is pending deletion"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:25
#, python-format
msgid ""
"The owner of the account sent a deletion request on %(date)s using their "
"app."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:26
#, python-format
msgid ""
"The account has been locked, and will be automatically deleted "
"permanently in %(time)s."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:28
msgid ""
"If this was a mistake, you can cancel the deletion and restore the "
"account."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:34
msgid "This user account is locked"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:35
msgid ""
"The user will not be able to log in to their account until it is unlocked"
" again."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:41
msgid "Edit user"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:46
msgid "The login name cannot be changed."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:33
#: snikket_web/templates/admin_edit_user.html:54
msgid ""
"The access level of a user determines what interactions are allowed for "
"them on your Snikket service."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:40
#: snikket_web/templates/admin_edit_user.html:61
#, python-format
msgid "<strong>%(title)s%(icon)s</strong><p>%(description)s</p>"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:50
#: snikket_web/templates/admin_edit_user.html:71
msgid "Return to user list"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:58
#: snikket_web/templates/admin_edit_user.html:79
msgid "Further actions"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:60
#: snikket_web/templates/admin_edit_user.html:81
msgid "Reset password"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:63
#: snikket_web/templates/admin_edit_user.html:84
msgid ""
"If the user has lost their password, you can use the button below to "
"create a special link which allows to change the password of the account,"
" once."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:68
#: snikket_web/templates/admin_edit_user.html:89
msgid "Debug information"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:70
#: snikket_web/templates/admin_edit_user.html:91
msgid ""
"In some cases, extended information about the user account and the "
"connected devices is necessary to troubleshoot issues. The button below "
"reveals this (sensitive) information."
msgstr ""
#: snikket_web/templates/admin_edit_user.html:74
#: snikket_web/templates/admin_edit_user.html:95
msgid "Show debug information"
msgstr ""
@@ -1048,20 +1121,20 @@ msgid ""
"your Snikket server. Use it wisely."
msgstr ""
#: snikket_web/templates/admin_users.html:19
msgid "The user is an administrator."
#: snikket_web/templates/admin_users.html:8
msgid "User"
msgstr ""
#: snikket_web/templates/admin_users.html:19
msgid " (Administrator)"
#: snikket_web/templates/admin_users.html:9
msgid "Last active"
msgstr ""
#: snikket_web/templates/admin_users.html:22
msgid "The user is restricted."
msgid "Deleted"
msgstr ""
#: snikket_web/templates/admin_users.html:22
msgid " (Restricted)"
#: snikket_web/templates/admin_users.html:24
msgid "Locked"
msgstr ""
#: snikket_web/templates/app.html:4
@@ -1455,19 +1528,35 @@ msgstr ""
msgid "First install Snikket from F-Droid using the button below:"
msgstr ""
#: snikket_web/templates/library.j2:18
#: snikket_web/templates/library.j2:19
msgid "The user is an administrator."
msgstr ""
#: snikket_web/templates/library.j2:19
msgid " (Administrator)"
msgstr ""
#: snikket_web/templates/library.j2:23
msgid "The user is restricted."
msgstr ""
#: snikket_web/templates/library.j2:23
msgid " (Restricted)"
msgstr ""
#: snikket_web/templates/library.j2:41
msgid "Copy link"
msgstr ""
#: snikket_web/templates/library.j2:81
#: snikket_web/templates/library.j2:104
msgid "Invalid input"
msgstr ""
#: snikket_web/templates/library.j2:122
#: snikket_web/templates/library.j2:145
msgid "Can be used multiple times to create accounts on this Snikket service."
msgstr ""
#: snikket_web/templates/library.j2:124
#: snikket_web/templates/library.j2:147
msgid "Can be used once to create an account on this Snikket service."
msgstr ""

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

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"