Implement group support (we call ’em circles)

This commit is contained in:
Jonas Schäfer
2021-01-18 17:36:43 +01:00
parent 006fea97a6
commit 17efe53106
20 changed files with 1165 additions and 233 deletions

View File

@@ -1,18 +1,28 @@
import asyncio
import typing
from datetime import datetime
import aiohttp
import quart.flask_patch
import wtforms
import wtforms.fields.html5
from quart import (Blueprint, render_template, redirect, url_for)
from quart import (
Blueprint,
render_template,
redirect,
url_for,
request,
)
import flask_wtf
from flask_babel import lazy_gettext as _l
from .infra import client
from . import prosodyclient
from .infra import client, circle_name
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -67,10 +77,55 @@ async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
class InvitesListForm(flask_wtf.FlaskForm): # type:ignore
action_revoke = wtforms.StringField()
class InvitePost(flask_wtf.FlaskForm): # type:ignore
circles = wtforms.SelectMultipleField(
_l("Invite to circle"),
# NOTE: This is for when/if we ever support multi-group invites.
# also see the note in admin_create_invite_form.html
# option_widget=wtforms.widgets.CheckboxInput(),
widget=wtforms.widgets.Select(multiple=False),
validators=[wtforms.validators.InputRequired(
_l("At least one circle must be selected")
)],
)
lifetime = wtforms.SelectField(
_l("Valid for"),
choices=[
(3600, _l("One hour")),
(12*3600, _l("Twelve hours")),
(86400, _l("One day")),
(7*86400, _l("One week")),
(28*86400, _l("Four weeks")),
],
default=7*86400,
)
reusable = wtforms.BooleanField(
_l("Allow multiple uses"),
)
action_create_invite = wtforms.SubmitField(
_l("New invitation link")
)
async def init_choices(
self,
*,
circles: typing.Optional[typing.Collection[
prosodyclient.AdminGroupInfo
]] = None) -> None:
if circles is not None:
self.circles.choices = [
(circle.id_, circle_name(circle))
for circle in sorted(circles, key=lambda x: x.name)
]
return
return await self.init_choices(
circles=await client.list_groups()
)
@bp.route("/invitations", methods=["GET", "POST"])
@client.require_admin_session()
@@ -78,23 +133,34 @@ async def invitations() -> typing.Union[str, quart.Response]:
user_info = await client.get_user_info()
invites = sorted(
await client.list_invites(),
key=lambda x: x.created_at
key=lambda x: x.created_at,
reverse=True,
)
circles = sorted(
await client.list_groups(),
key=lambda x: x.name
)
circle_map = {
circle.id_: circle
for circle in circles
}
invite_form = InvitePost()
await invite_form.init_choices(circles=circles)
form = InvitesListForm()
if form.validate_on_submit():
if form.action_revoke.data:
await client.delete_invite(form.action_revoke.data)
if form.action_create_invite.data:
info = await client.create_invite()
return redirect(url_for(".edit_invite", id_=info.id_))
return redirect(url_for(".invitations"))
return await render_template(
"admin_invites.html",
user_info=user_info,
invites=invites,
invite_form=invite_form,
now=datetime.utcnow(),
circle_map=circle_map,
form=form,
)
@@ -105,11 +171,37 @@ class InviteForm(flask_wtf.FlaskForm): # type:ignore
)
@bp.route("/invitation/-/new", methods=["POST"])
@client.require_admin_session()
async def create_invite() -> typing.Union[str, quart.Response]:
user_info = await client.get_user_info()
form = InvitePost()
circles = await client.list_groups()
form.circles.choices = [
(c.id_, c.name) for c in circles
]
if form.validate_on_submit():
invite = await client.create_invite(
group_ids=form.circles.data,
reusable=form.reusable.data,
ttl=form.lifetime.data,
)
return redirect(url_for(".edit_invite", id_=invite.id_))
return await render_template("admin_create_invite.html",
user_info=user_info,
invite_form=form)
@bp.route("/invitation/<id_>", methods=["GET", "POST"])
@client.require_admin_session()
async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
user_info = await client.get_user_info()
invite_info = await client.get_invite_by_id(id_)
circles = await client.list_groups()
circle_map = {
circle.id_: circle
for circle in circles
}
form = InviteForm()
if form.validate_on_submit():
@@ -124,4 +216,120 @@ async def edit_invite(id_: str) -> typing.Union[str, quart.Response]:
invite=invite_info,
now=datetime.utcnow(),
form=form,
circle_map=circle_map,
)
class CirclePost(flask_wtf.FlaskForm): # type:ignore
name = wtforms.StringField(
_l("Name"),
)
action_create = wtforms.SubmitField(
_l("Create circle")
)
@bp.route("/circles")
@client.require_admin_session()
async def circles() -> str:
user_info = await client.get_user_info()
circles = sorted(
await client.list_groups(),
key=lambda x: x.name
)
invite_form = InvitePost()
create_form = CirclePost()
return await render_template(
"admin_circles.html",
circles=circles,
user_info=user_info,
invite_form=invite_form,
create_form=create_form,
)
@bp.route("/circle/-/new", methods=["POST"])
@client.require_admin_session()
async def create_circle() -> typing.Union[str, quart.Response]:
user_info = await client.get_user_info()
create_form = CirclePost()
if create_form.validate_on_submit():
circle = await client.create_group(
name=create_form.name.data,
)
return redirect(url_for(".edit_circle", id_=circle.id_))
return await render_template(
"admin_create_circle.html",
user_info=user_info,
create_form=create_form,
)
class EditCircleForm(flask_wtf.FlaskForm): # type:ignore
name = wtforms.StringField(
_l("Name"),
)
action_save = wtforms.SubmitField(
_l("Apply")
)
action_delete = wtforms.SubmitField(
_l("Delete circle permanently")
)
action_remove_user = wtforms.StringField()
@bp.route("/circle/<id_>", methods=["GET", "POST"])
@client.require_admin_session()
async def edit_circle(id_: str) -> typing.Union[str, quart.Response]:
async with client.authenticated_session() as session:
user_info = await client.get_user_info(
session=session,
)
try:
circle = await client.get_group_by_id(
id_,
session=session,
)
except aiohttp.ClientResponseError as exc:
if exc.status == 404:
return redirect(url_for(".circles"))
raise
circle_members = await asyncio.gather(*(
client.get_user_by_localpart(
localpart,
session=session,
)
for localpart in sorted(circle.members)
))
form = EditCircleForm()
invite_form = InvitePost()
await invite_form.init_choices()
invite_form.circles.data = [id_]
if request.method != "POST":
form.name.data = circle.name
if form.validate_on_submit():
if form.action_save.data:
# TODO: post update
pass
elif form.action_delete.data:
await client.delete_group(id_)
return redirect(url_for(".circles"))
return redirect(url_for(".edit_circle", id_=id_))
return await render_template(
"admin_edit_circle.html",
target_circle=circle,
user_info=user_info,
form=form,
circle_members=circle_members,
invite_form=invite_form,
)

View File

@@ -8,6 +8,7 @@ from quart import (
)
import flask_babel
from flask_babel import _
from . import prosodyclient
@@ -20,9 +21,11 @@ babel = flask_babel.Babel()
@babel.localeselector # type:ignore
def selected_locale() -> str:
return request.accept_languages.best_match(
selected = request.accept_languages.best_match(
current_app.config['LANGUAGES']
)
print(request.accept_languages, current_app.config["LANGUAGES"], selected)
return selected
def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
@@ -31,6 +34,12 @@ def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
return a
def circle_name(c: typing.Any) -> str:
if c.id_ == "default" and c.name == "default":
return _("Main")
return c.name
def init_templating(app: quart.Quart) -> None:
app.template_filter("repr")(repr)
app.template_filter("format_datetime")(flask_babel.format_datetime)
@@ -38,3 +47,4 @@ def init_templating(app: quart.Quart) -> None:
app.template_filter("format_time")(flask_babel.format_time)
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name)

View File

@@ -39,6 +39,10 @@ class LoginForm(flask_wtf.FlaskForm): # type:ignore
validators=[wtforms.validators.InputRequired()],
)
action_signin = wtforms.SubmitField(
_l("Sign in"),
)
@bp.route("/login", methods=["GET", "POST"])
async def login() -> typing.Union[str, quart.Response]:

View File

@@ -72,6 +72,8 @@ class AdminInviteInfo:
landing_page: typing.Optional[str]
created_at: datetime
expires: datetime
reusable: bool
group_ids: typing.Collection[str]
@classmethod
def from_api_response(
@@ -87,6 +89,26 @@ class AdminInviteInfo:
token=data["id"],
xmpp_uri=data.get("xmpp_uri"),
landing_page=data.get("landing_page"),
group_ids=data.get("groups", []),
reusable=data["reusable"],
)
@dataclasses.dataclass(frozen=True)
class AdminGroupInfo:
id_: str
name: str
members: typing.Collection[str]
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminGroupInfo":
return cls(
id_=data["id"],
name=data["name"],
members=data["members"],
)
@@ -305,6 +327,9 @@ class ProsodyClient:
def has_session(self) -> bool:
return self.SESSION_TOKEN in http_session
def authenticated_session(self) -> HTTPAuthSessionManager:
return self._auth_session
def require_session(
self,
redirect_to: typing.Optional[str] = None,
@@ -394,29 +419,33 @@ class ProsodyClient:
)
return ET.fromstring(reply_payload)
async def get_user_info(self) -> typing.Mapping:
@autosession
async def get_user_info(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Mapping:
localpart, domain, _ = split_jid(self.session_address)
async with self._auth_session as session:
nickname = await self.get_user_nickname(session=session)
try:
avatar_info = await self.get_avatar(
self.session_address,
metadata_only=True,
session=session,
)
avatar_hash = avatar_info["sha1"]
except quart.exceptions.HTTPException:
avatar_hash = None
nickname = await self.get_user_nickname(session=session)
try:
avatar_info = await self.get_avatar(
self.session_address,
metadata_only=True,
session=session,
)
avatar_hash = avatar_info["sha1"]
except quart.exceptions.HTTPException:
avatar_hash = None
return {
"address": self.session_address,
"username": localpart,
"nickname": nickname,
"display_name": nickname or localpart,
"avatar_hash": avatar_hash,
"is_admin": self.is_admin_session,
}
return {
"address": self.session_address,
"username": localpart,
"nickname": nickname,
"display_name": nickname or localpart,
"avatar_hash": avatar_hash,
"is_admin": self.is_admin_session,
}
@autosession
async def test_session(self, session: aiohttp.ClientSession) -> bool:
@@ -698,7 +727,7 @@ class ProsodyClient:
if resp.status == 400:
abort(500, "request rejected by backend")
if not 200 <= resp.status < 300:
abort(resp.status)
resp.raise_for_status()
@autosession
async def list_users(
@@ -777,13 +806,89 @@ class ProsodyClient:
@autosession
async def create_invite(
self,
group_ids: typing.Collection[str],
reusable: bool,
ttl: int,
*,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
async with session.put(self._admin_v1_endpoint("/invites")) as resp:
payload = {
"reusable": reusable,
"groups": list(group_ids),
"ttl": ttl,
}
async with session.post(
self._admin_v1_endpoint("/invites"),
json=payload) as resp:
self._raise_error_from_response(resp)
return AdminInviteInfo.from_api_response(await resp.json())
@autosession
async def create_group(
self,
name: str,
*,
session: aiohttp.ClientSession,
) -> AdminGroupInfo:
payload = {
"name": name,
}
async with session.post(
self._admin_v1_endpoint("/groups"),
json=payload) as resp:
self._raise_error_from_response(resp)
return AdminGroupInfo.from_api_response(await resp.json())
@autosession
async def list_groups(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Collection[AdminGroupInfo]:
async with session.get(self._admin_v1_endpoint("/groups")) as resp:
self._raise_error_from_response(resp)
return list(map(
AdminGroupInfo.from_api_response,
await resp.json(),
))
@autosession
async def get_group_by_id(
self,
id_: str,
*,
session: aiohttp.ClientSession,
) -> AdminGroupInfo:
async with session.get(
self._admin_v1_endpoint("/groups/{}".format(id_)),
) as resp:
self._raise_error_from_response(resp)
return AdminGroupInfo.from_api_response(await resp.json())
@autosession
async def update_group(
self,
id_: str,
*,
new_name: typing.Optional[str] = None,
session: aiohttp.ClientSession,
) -> AdminGroupInfo:
pass
@autosession
async def delete_group(
self,
id_: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint("/groups/{}".format(id_)),
) as resp:
self._raise_error_from_response(resp)
async def logout(self) -> None:
# this currently only kills the cookie stuff, we may want to invalidate
# the token on the server side, toos

View File

@@ -250,3 +250,4 @@ $h-sizes: [200.0%, 174.11011266%, 151.57165665%, 131.95079108%, 114.8698355%, 10
*/
$h-small-sizes: [150.0%, 138.31618672%, 127.54245006%, 117.60790225%, 108.44717712%, 100.0%];
$small-screen-threshold: 40rem;
$medium-screen-threshold: 60rem;

View File

@@ -805,6 +805,22 @@ table {
}
}
div.elevated {
margin: $w-l1;
padding: $w-l1;
background-color: white;
}
div.elevated > *:first-child {
margin-top: 0;
margin-bottom: 0;
}
div.elevated > *:last-child {
margin-top: 0;
margin-bottom: 0;
}
.long-url-link {
display: block;
overflow: hidden;
@@ -820,15 +836,47 @@ table {
display: none;
}
ul.inline {
display: inline;
margin: 0;
padding: 0;
list-style-type: none;
> li {
display: inline-block;
padding: 0;
margin: 0;
}
> li:before {
content: ', ';
}
> li:first-child:before {
content: '';
}
}
.nowrap {
white-space: nowrap;
}
/* linearisation / responsive stuff */
@media screen and (max-width: $small-screen-threshold) {
main > .form.layout-expanded {
@media screen and (max-width: $medium-screen-threshold) {
.form.layout-expanded {
margin-left: 0;
margin-right: 0;
}
div.elevated {
margin-left: 0;
margin-right: 0;
}
}
@media screen and (max-width: $small-screen-threshold) {
.form.layout-expanded .box {
margin-left: 0;
margin-right: 0;

View File

@@ -0,0 +1,51 @@
{% extends "admin_app.html" %}
{% from "library.j2" import action_button, custom_form_button, form_button, circle_name %}
{% block content %}
<h1>{% trans %}Manage circles{% endtrans %}</h1>
{%- if circles -%}
<form method="POST" action="{{ url_for(".create_invite") }}">
{{- invite_form.csrf_token -}}
<div class="elevated el-2"><table>
<thead>
<tr>
<th>{% trans %}Circle name{% endtrans %}</th>
<th class="collapsible">{% trans %}Members{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for circle in circles %}
<tr>
<td>{{ circle | circle_name }}</td>
<td class="collapsible">{{ circle.members | length }}</td>
<td class="nowrap">
{%- call custom_form_button("create_link", invite_form.circles.name, circle.id_, slim=True, class="secondary accent") -%}
{% trans circle_name=circle.name %}Create invitation to circle {{ circle_name }}{% endtrans %}
{%- endcall -%}
{%- call action_button("more", url_for(".edit_circle", id_=circle.id_), class="primary") -%}
{% trans circle_name=circle.name %}Show details of circle {{ circle_name }}{% endtrans %}
{%- endcall -%}
</td>
</tr>
{% endfor %}
</tbody>
</table></div></form>
{%- else -%}
<div class="box primary">
<header>{% trans %}No circles{% endtrans %}</header>
<p>{% trans %}Currently, there are no circles on this instance. Use the form below to create one.{% endtrans %}</p>
</div>
{%- endif -%}
<h2>{% trans %}New circle{% endtrans %}</h2>
<form method="POST" action="{{ url_for(".create_circle") }}"><div class="form layout-expanded">
{{- create_form.csrf_token -}}
<h2 class="form-title">{% trans %}Create circle{% endtrans %}</h2>
<div class="f-ebox">
{{- create_form.name.label -}}
{{- create_form.name -}}
</div>
<div class="f-bbox">
{%- call form_button("create_group", create_form.action_create, class="primary") -%}{%- endcall -%}
</div>
</div></form>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends "admin_app.html" %}
{% block content %}
<h1>{% trans %}Create invitation{% endtrans %}</h1>
{%- include "admin_create_invite_form.html" -%}
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% from "library.j2" import form_button, render_errors %}
<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 instance by clicking the button below.{% endtrans %}</p>
<div class="f-ebox">
{{ invite_form.reusable }}
{{ invite_form.reusable.label }}
</div>
<div class="f-ebox">
{{ invite_form.lifetime.label }}
<div class="select-wrap">{{ invite_form.lifetime }}</div>
</div>
<div class="f-ebox">
{#
NOTE: This is for when/if we ever support multi-group invites.
Also see the NOTE in admin.py
{{ invite_form.circles.label(class="required") }}
{%- for choice in invite_form.circles -%}
{{ choice }}{{ choice.label }}
{%- endfor -%}
#}
{{- invite_form.circles.label -}}
<div class="select-wrap">{{ invite_form.circles }}</div>
{%- call render_errors(invite_form.circles) -%}{%- endcall -%}
</div>
<div class="f-bbox">
{%- call form_button("create_link", invite_form.action_create_invite, class="primary") %}{% endcall -%}
</div>
</div></form>

View File

@@ -0,0 +1,48 @@
{% extends "admin_app.html" %}
{% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button %}
{% block content %}
<h1>{% trans circle_name=(target_circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}Circle information{% endtrans %}</h2>
{{ form.csrf_token }}
<div class="f-ebox">
{{ form.name.label }}
{{ form.name }}
</div>
<div class="f-bbox">
{%- call standard_button("back", url_for(".circles"), class="secondary") -%}
{% trans %}Back{% endtrans %}
{%- endcall -%}
{%- call form_button("done", form.action_save, class="primary") %}{% endcall -%}
</div>
<h3 class="form-title">{% trans %}Delete circle{% endtrans %}</h3>
<p class="form-desc">{% trans %}Deleting a circle does not delete any users in the circle.{% endtrans %}</p>
<div class="f-bbox">
{%- call form_button("done", form.action_delete, class="secondary danger") %}{% endcall -%}
</div>
</div>
<h2>{% trans %}Circle members{% endtrans %}</h2>
<div class="el-2 elevated"><table>
<thead>
<th>Login name</th>
<th class="collapsible">Display name</th>
<th>Actions</th>
</thead>
<tbody>
{%- for member in circle_members -%}
<tr>
<td>{{ member.localpart }}</td>
<td class="collapsible">{% call value_or_hint(member.display_name) %}{% endcall %}</td>
<td class="nowrap">
{%- call custom_form_button("remove", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%}
{% trans username=member.localpart %}Remove user {{ username }} from circle{% endtrans %}
{%- endcall -%}
</td>
</tr>
{%- endfor -%}
</tbody>
</table></div>
</form>
<h3>{% trans %}Invite more members{% endtrans %}</h3>
{%- include "admin_create_invite_form.html" -%}
{% endblock %}

View File

@@ -10,12 +10,34 @@
{{ form.csrf_token }}
<div class="form layout-expanded">
<dl>
<dt>{% trans %}Created{% endtrans %}</dt>
<dd>{{ invite.created_at | format_date }}</dd>
<dt>{% trans %}Valid until{% endtrans %}</dt>
<dd>{{ invite.expires | format_date }}</dd>
<dt>{% trans %}Link{% endtrans %}</dt>
<dd>{% call showuri(invite.landing_page) %}{% endcall %}</dd>
<dt>{% trans %}Reusability{% endtrans %}</dt>
<dd>{% if invite.reusable %}{% trans %}This invitation link can be used arbitrarily often, until it expires, is revoked or a server-wide user limit is reached.{% endtrans %}{% else %}{% trans %}This invitation link can only be used once and is then depleted.{% endtrans %}{% endif %}</dd>
{%- set ngroups = invite.group_ids | length -%}
{%- if ngroups > 1 -%}
{#- not supported via the web UI, but we should still display it properly -#}
<dt>{% trans %}Circles{% endtrans %}</dt>
<dd><p>{% trans %}Users joining via this invitation will be added to the following circles:{% endtrans %}</p><ul>
{%- for group_id in invite.group_ids -%}
<li>{{ circle_map[group_id] | circle_name }}</li>
{%- endfor -%}
</ul></dd>
{%- else -%}
<dt>{% trans %}Circle{% endtrans %}</dt>
<dd>
{%- if ngroups == 1 -%}
{%- set group_id = invite.group_ids[0] -%}
<a href="{{ url_for(".edit_circle", id_=group_id) }}">{{ circle_map[invite.group_ids[0]] | circle_name }}</a>
{%- else -%}
<em>{% trans %}The user will not be added to any circle and will have no contacts.{% endtrans %}</em>
{%- endif -%}
</dd>
{%- endif -%}
<dt>{% trans %}Created{% endtrans %}</dt>
<dd>{{ invite.created_at | format_date }}</dd>
</dl>
<div class="f-bbox">
{%- call form_button("remove_link", form.action_revoke, class="secondary danger") %}{% endcall -%}

View File

@@ -7,6 +7,9 @@
<h2>{% trans %}Manage users{% endtrans %}</h2>
<p>{% trans %}Modify administrative user information or delete users.{% endtrans %}</p>
</a>
<a class="card" href="{{ url_for('.circles') }}">
<h2>{% trans %}Manage circles{% endtrans %}</h2>
</a>
<a class="card" href="{{ url_for('.invitations') }}">
<h2>{% trans %}Manage invitations{% endtrans %}</h2>
<p>{% trans %}Create, revoke or view invitations.{% endtrans %}</p>

View File

@@ -6,33 +6,40 @@
{% endblock %}
{% block content %}
<h1>{% trans %}Manage invitations{% endtrans %}</h1>
<form method="POST">{{ 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 instance by clicking the button below.{% endtrans %}</p>
<div class="f-bbox">
{%- call form_button("create_link", form.action_create_invite, class="primary") %}{% endcall -%}
</div>
</div>
{%- include "admin_create_invite_form.html" -%}
<h2>{% trans %}Pending invitations{% endtrans %}</h2>
{% if invites %}
<table>
<col/>
<form method="POST">
{{- form.csrf_token -}}
<div class="elevated el-2"><table>
<col/>
<col class="collapsible"/>
<col class="collapsible"/>
<col/>
<thead>
<tr>
<th>{% trans %}Created{% endtrans %}</th>
<th>{% trans %}Valid until{% endtrans %}</th>
<th class="collapsible">{% trans %}Reusable{% endtrans %}</th>
<th class="collapsible">{% trans %}Circle{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for invite in invites %}
<tr>
<td>{{ invite.created_at | format_date }}</td>
<td>{{ (invite.expires - now) | format_timedelta(add_direction=True) }}</td>
<td style="white-space: nowrap;">
<td class="collapsible">{% if invite.reusable %}{% trans %}Yes{% endtrans %}{% else %}{% trans
%}No{% endtrans %}{% endif %}</td>
<td class="collapsible">
{#- -#}
<ul class="inline">
{%- for group_id in invite.group_ids -%}
<li>{{ circle_map[group_id] | circle_name }}</li>
{%- endfor -%}
</ul>
{#- -#}
</td>
<td class="nowrap">
{%- call action_button("more", url_for(".edit_invite", id_=invite.id_), class="secondary") -%}
{% trans %}Show invite details{% endtrans %}
{%- endcall -%}
@@ -46,9 +53,8 @@
</tr>
{% endfor %}
</tbody>
</table>
</table></form></div>
{% else %}
<p>{% trans %}Currently, there are no pending invitations.{% endtrans %}</p>
{% endif %}
</form>
{% endblock %}

View File

@@ -1,15 +1,8 @@
{% extends "admin_app.html" %}
{% from "library.j2" import icon %}
{% macro value_or_hint(v, caller=None) %}
{%- if v is not none -%}
{{- v -}}
{%- else -%}
{%- endif -%}
{% endmacro %}
{% from "library.j2" import action_button, value_or_hint %}
{% block content %}
<h1>{% trans %}Manage users{% endtrans %}</h1>
<table>
<div class="elevated el-2"><table>
<thead>
<tr>
<th>{% trans %}Login name{% endtrans %}</th>
@@ -27,11 +20,12 @@
<td class="collapsible">{% call value_or_hint(user.email) %}{% endcall %}</td>
<td class="collapsible">{% call value_or_hint(user.phone) %}{% endcall %}</td>
<td>
{#- -#}<a class="button secondary btn-delete" href="{{ url_for(".delete_user", localpart=user.localpart) }}">{% call icon("remove") %}{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}{% endcall %}</a>
{#- -#}
{%- call action_button("remove", url_for(".delete_user", localpart=user.localpart), class="secondary") -%}
{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}
{%- endcall -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</table></div>
{% endblock %}

View File

@@ -72,3 +72,28 @@
{% endif -%}
</a>
{%- endmacro %}
{% macro render_errors(field, caller=None) -%}
{%- if field.errors -%}
<div class="box warning">{#- -#}
<header>{% trans %}Invalid input{% endtrans %}</header>
{%- if field.errors | length == 1 -%}
<p>{{ field.errors[0] }}.</p>
{%- else -%}
<ul>
{%- for error in field.errors -%}
<li>{{ error }}</li>
{%- endfor -%}
</ul>
{%- endif -%}
</div>
{%- endif -%}
{%- endmacro %}
{% macro value_or_hint(v, caller=None) %}
{%- if v is not none -%}
{{- v -}}
{%- else -%}
{%- endif -%}
{% endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "library.j2" import box, icon %}
{% from "library.j2" import box, form_button %}
{% set body_id = "login" %}
{% block head_lead %}
<title>{{ _("Snikket Login") }}</title>
@@ -28,7 +28,7 @@
{{ form.password(placeholder=form.password.label.text) }}
</div>
<div class="f-bbox">
<button type="submit" class="primary">{% call icon("login") %}{% endcall %}{{ _("Log in") }}</button>
{%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%}
</div>
</from>
</div></main></div>

View File

@@ -1,5 +1,5 @@
{% extends "app.html" %}
{% from "library.j2" import icon %}
{% from "library.j2" import standard_button, form_button %}
{% block head_lead %}
<title>Snikket Web Portal</title>
{% endblock %}
@@ -9,11 +9,10 @@
<p class="form-desc">{% trans %}Click below to log yourself out of the web portal. This does not affect any other connected devices.{% endtrans %}</p>
{{ form.csrf_token }}
<div class="f-bbox">
{#- -#}
<a href="{{ url_for('user.index') }}" class="button secondary">{% call icon("back") %}{% endcall %}{% trans %}Back{% endtrans %}</a>
{#- -#}
<button type="submit" class="primary">{% call icon("logout") %}{% endcall %}{% trans %}Sign out{% endtrans %}</button>
{#- -#}
{%- call standard_button("back", url_for("user.index"), class="secondary") -%}
{% trans %}Back{% endtrans %}
{%- endcall -%}
{%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%}
</div>
</form></div>
{% endblock %}

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: SnikketWeb 0.1.0\n"
"Report-Msgid-Bugs-To: jonas@zombofant.net\n"
"POT-Creation-Date: 2021-01-17 20:13+0100\n"
"POT-Creation-Date: 2021-01-21 16:54+0100\n"
"PO-Revision-Date: 2020-03-07 16:32+0100\n"
"Last-Translator: Jonas Schäfer <jonas@zombofant.net>\n"
"Language: de\n"
@@ -18,18 +18,74 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n"
#: snikket_web/admin.py:44
#: snikket_web/admin.py:54
msgid "Delete user permanently"
msgstr "Benutzer endgültig löschen"
#: snikket_web/admin.py:71
#: snikket_web/admin.py:83
msgid "Invite to circle"
msgstr "In Gemeinschaft einladen"
#: snikket_web/admin.py:89
msgid "At least one circle must be selected"
msgstr "Mindestens eine Gemeinschaft muss ausgewählt sein"
#: snikket_web/admin.py:94
msgid "Valid for"
msgstr "Gültig für"
#: snikket_web/admin.py:96
msgid "One hour"
msgstr "Eine Stunde"
#: snikket_web/admin.py:97
msgid "Twelve hours"
msgstr "Zwölf Stunden"
#: snikket_web/admin.py:98
msgid "One day"
msgstr "Ein Tag"
#: snikket_web/admin.py:99
msgid "One week"
msgstr "Eine Woche"
#: snikket_web/admin.py:100
msgid "Four weeks"
msgstr "Vier Wochen"
#: snikket_web/admin.py:106
msgid "Allow multiple uses"
msgstr "Mehrfach verwendbar"
#: snikket_web/admin.py:110
msgid "New invitation link"
msgstr "Neuer Einladungslink"
#: snikket_web/admin.py:104
#: snikket_web/admin.py:170
msgid "Revoke"
msgstr "Löschen"
#: snikket_web/admin.py:225 snikket_web/admin.py:272
msgid "Name"
msgstr "Name"
#: snikket_web/admin.py:229 snikket_web/templates/admin_circles.html:42
msgid "Create circle"
msgstr "Gemeinschaft gründen"
#: snikket_web/admin.py:276 snikket_web/user.py:73
msgid "Apply"
msgstr "Übernehmen"
#: snikket_web/admin.py:280
msgid "Delete circle permanently"
msgstr "Gemeinschaft endgültig löschen"
#: snikket_web/infra.py:39
msgid "Main"
msgstr "Kern"
#: snikket_web/main.py:33
msgid "Address"
msgstr "Adresse"
@@ -38,7 +94,11 @@ msgstr "Adresse"
msgid "Password"
msgstr "Passwort"
#: snikket_web/main.py:60
#: snikket_web/main.py:43
msgid "Sign in"
msgstr "Anmelden"
#: snikket_web/main.py:64
msgid "Invalid user name or password."
msgstr "Benutzername oder Passwort falsch."
@@ -58,38 +118,103 @@ msgstr "Neues Passwort (Bestätigung)"
msgid "The new passwords must match."
msgstr "Die neuen Passwörter müssen übereinstimmen."
#: snikket_web/user.py:50
#: snikket_web/user.py:47
msgid "Sign out"
msgstr "Abmelden"
#: snikket_web/user.py:52
msgid "Nobody"
msgstr "Niemand"
#: snikket_web/user.py:51
#: snikket_web/user.py:53
msgid "Friends only"
msgstr "Nur Freunde"
#: snikket_web/user.py:52
#: snikket_web/user.py:54
msgid "Everyone"
msgstr "Jeder"
#: snikket_web/templates/admin_delete_user.html:12
#: snikket_web/templates/admin_delete_user.html:16
#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:58
#: snikket_web/templates/admin_users.html:9 snikket_web/user.py:60
msgid "Display name"
msgstr "Anzeigename"
#: snikket_web/user.py:62
#: snikket_web/user.py:64
msgid "Avatar"
msgstr "Bild"
#: snikket_web/user.py:66
#: snikket_web/user.py:68
msgid "Profile visibility"
msgstr "Profilsichtbarkeit"
#: snikket_web/user.py:91
#: snikket_web/user.py:97
msgid "Incorrect password"
msgstr "Ungültiges Passwort"
#: snikket_web/templates/admin_circles.html:4
#: snikket_web/templates/admin_home.html:11
msgid "Manage circles"
msgstr "Gemeinschaften verwalten"
#: snikket_web/templates/admin_circles.html:11
msgid "Circle name"
msgstr "Name"
#: snikket_web/templates/admin_circles.html:12
msgid "Members"
msgstr "Mitglieder"
#: snikket_web/templates/admin_circles.html:13
#: snikket_web/templates/admin_invites.html:24
#: snikket_web/templates/admin_users.html:12
msgid "Actions"
msgstr "Aktionen"
#: snikket_web/templates/admin_circles.html:23
#, python-format
msgid "Create invitation to circle %(circle_name)s"
msgstr "Einladung in die %(circle_name)s Gemeinschaft erzeugen"
#: snikket_web/templates/admin_circles.html:26
#, python-format
msgid "Show details of circle %(circle_name)s"
msgstr "Details der %(circle_name)s Gemeinschaft anzeigen"
#: snikket_web/templates/admin_circles.html:35
msgid "No circles"
msgstr "Keine Gemeinschaften"
#: snikket_web/templates/admin_circles.html:36
msgid ""
"Currently, there are no circles on this instance. Use the form below to "
"create one."
msgstr ""
"Es gibt derzeit keine Gemeinschaften auf dieser Instanz. Unten kannst du "
"eine anlegen."
#: snikket_web/templates/admin_circles.html:39
msgid "New circle"
msgstr "Neue Gemeinschaft"
#: snikket_web/templates/admin_create_invite.html:3
msgid "Create invitation"
msgstr "Gemeinschaft gründen"
#: snikket_web/templates/admin_create_invite_form.html:5
msgid "Create new invitation"
msgstr "Neue Einladung erzeugen"
#: snikket_web/templates/admin_create_invite_form.html:6
msgid ""
"Create a new invitation link to invite more users to your Snikket "
"instance by clicking the button below."
msgstr ""
"Erzeuge eine neue Einladung um mehr Benutzer auf deine Snikket-Instanz "
"einzuladen indem du den folgenden Button klickst."
#: snikket_web/templates/admin_delete_user.html:4
#: snikket_web/templates/admin_users.html:29
#: snikket_web/templates/admin_users.html:24
#, python-format
msgid "Delete user %(user_name)s"
msgstr "Benutzer %(user_name)s löschen"
@@ -104,12 +229,12 @@ msgid "Are you sure you want to delete the following user?"
msgstr "Bist du sicher dass du den folgenden Benutzer löschen willst?"
#: snikket_web/templates/admin_delete_user.html:10
#: snikket_web/templates/admin_users.html:14
#: snikket_web/templates/admin_users.html:8
msgid "Login name"
msgstr "Anmeldename"
#: snikket_web/templates/admin_delete_user.html:14
#: snikket_web/templates/admin_users.html:16
#: snikket_web/templates/admin_users.html:10
msgid "Email address"
msgstr "E-Mail-Adresse"
@@ -117,30 +242,100 @@ msgstr "E-Mail-Adresse"
msgid "Danger"
msgstr "Gefahr"
#: snikket_web/templates/admin_delete_user.html:23
#: snikket_web/templates/admin_edit_circle.html:14
#: snikket_web/templates/admin_edit_invite.html:45
#: snikket_web/templates/user_logout.html:13
#: snikket_web/templates/user_passwd.html:40
#: snikket_web/templates/user_profile.html:25
msgid "Back"
msgstr "Zurück"
#: snikket_web/templates/admin_edit_circle.html:4
#, python-format
msgid "Edit circle %(circle_name)s"
msgstr "Gemeinschaft %(circle_name)s bearbeiten"
#: snikket_web/templates/admin_edit_circle.html:6
msgid "Circle information"
msgstr "Gemeinschaftsinformationen"
#: snikket_web/templates/admin_edit_circle.html:18
msgid "Delete circle"
msgstr "Gemeinschaft löschen"
#: snikket_web/templates/admin_edit_circle.html:19
msgid "Deleting a circle does not delete any users in the circle."
msgstr ""
"Wenn eine Gemeinschaft gelöscht wird, werden die Benutzer die zu dieser "
"Gemeinschaft gehören nicht gelöscht."
#: snikket_web/templates/admin_edit_circle.html:24
msgid "Circle members"
msgstr "Mitglieder der Gemeinschaft"
#: snikket_web/templates/admin_edit_circle.html:38
#, python-format
msgid "Remove user %(username)s from circle"
msgstr "Benutzer %(username)s aus der Gemeinschaft entfernen"
#: snikket_web/templates/admin_edit_circle.html:46
msgid "Invite more members"
msgstr "Mehr Mitglieder einladen"
#: snikket_web/templates/admin_edit_invite.html:8
msgid "View invitation"
msgstr "Einladung anzeigen"
#: snikket_web/templates/admin_edit_invite.html:13
#: snikket_web/templates/admin_invites.html:24
msgid "Created"
msgstr "Erzeugt"
#: snikket_web/templates/admin_edit_invite.html:15
#: snikket_web/templates/admin_invites.html:25
#: snikket_web/templates/admin_invites.html:21
msgid "Valid until"
msgstr "Gültig bis"
#: snikket_web/templates/admin_edit_invite.html:17
#: snikket_web/templates/admin_edit_invite.html:15
msgid "Link"
msgstr "Link"
#: snikket_web/templates/admin_edit_invite.html:24
#: snikket_web/templates/user_logout.html:12
#: snikket_web/templates/user_passwd.html:39
#: snikket_web/templates/user_profile.html:24
msgid "Back"
msgstr "Zurück"
#: snikket_web/templates/admin_edit_invite.html:17
msgid "Reusability"
msgstr "Wiederverwendbarkeit"
#: snikket_web/templates/admin_edit_invite.html:18
msgid ""
"This invitation link can be used arbitrarily often, until it expires, is "
"revoked or a server-wide user limit is reached."
msgstr ""
"Diese Einladung kann beliebig oft verwendet werden, bis sie abläuft, "
"gelöscht wird oder ein instanzweites Limit erreicht ist."
#: snikket_web/templates/admin_edit_invite.html:18
msgid "This invitation link can only be used once and is then depleted."
msgstr "Diese Einladung kann nur einmal verwendet werden und ist dann ungültig."
#: snikket_web/templates/admin_edit_invite.html:22
msgid "Circles"
msgstr "Gemeinschaften"
#: snikket_web/templates/admin_edit_invite.html:23
msgid "Users joining via this invitation will be added to the following circles:"
msgstr ""
"Benutzer die über diese Einladung zur Instanz stoßen werden zu den "
"folgenden Gemeinschaften hinzugefügt:"
#: snikket_web/templates/admin_edit_invite.html:29
#: snikket_web/templates/admin_invites.html:23
msgid "Circle"
msgstr "Gemeinschaft"
#: snikket_web/templates/admin_edit_invite.html:35
msgid "The user will not be added to any circle and will have no contacts."
msgstr ""
"Benutzer werden zu keiner Gemeinschaft hinzugefügt und werden zu Beginn "
"keine Kontakte haben."
#: snikket_web/templates/admin_edit_invite.html:39
msgid "Created"
msgstr "Erzeugt"
#: snikket_web/templates/admin_edit_user.html:3
#, python-format
@@ -178,7 +373,7 @@ msgid "At your service, %(user_name)s."
msgstr "Zu deinen Diensten, %(user_name)s."
#: snikket_web/templates/admin_home.html:7
#: snikket_web/templates/admin_users.html:10
#: snikket_web/templates/admin_users.html:4
msgid "Manage users"
msgstr "Benutzer verwalten"
@@ -186,61 +381,56 @@ msgstr "Benutzer verwalten"
msgid "Modify administrative user information or delete users."
msgstr "Benutzerinformationen verändern oder Benutzer löschen."
#: snikket_web/templates/admin_home.html:11
#: snikket_web/templates/admin_invites.html:7
#: snikket_web/templates/admin_home.html:14
#: snikket_web/templates/admin_invites.html:8
msgid "Manage invitations"
msgstr "Einladungen verwalten"
#: snikket_web/templates/admin_home.html:12
#: snikket_web/templates/admin_home.html:15
msgid "Create, revoke or view invitations."
msgstr "Einladungen erzeugen, löschen oder anzeigen."
#: snikket_web/templates/admin_home.html:15
#: snikket_web/templates/admin_home.html:18
msgid "Back to the main view"
msgstr "Zurück zur Hauptseite"
#: snikket_web/templates/admin_home.html:16
#: snikket_web/templates/admin_home.html:19
msgid "Go back to your users web portal page."
msgstr "Zurück zur Startseite deines Benutzers."
#: snikket_web/templates/admin_invites.html:10
msgid "Create new invitation"
msgstr "Neue Einladung erzeugen"
#: snikket_web/templates/admin_invites.html:11
msgid ""
"Create a new invitation link to invite more users to your Snikket "
"instance by clicking the button below."
msgstr ""
"Erzeuge eine neue Einladung um mehr Benutzer auf deine Snikket-Instanz "
"einzuladen indem du den folgenden Button klickst."
#: snikket_web/templates/admin_invites.html:16
msgid "Pending invitations"
msgstr "Ausstehende Einladungen"
#: snikket_web/templates/admin_invites.html:26
#: snikket_web/templates/admin_users.html:18
msgid "Actions"
msgstr "Aktionen"
#: snikket_web/templates/admin_invites.html:22
msgid "Reusable"
msgstr "Mehrfach"
#: snikket_web/templates/admin_invites.html:36
#: snikket_web/templates/admin_invites.html:31
msgid "Yes"
msgstr "Ja"
#: snikket_web/templates/admin_invites.html:31
msgid "No"
msgstr "Nein"
#: snikket_web/templates/admin_invites.html:44
msgid "Show invite details"
msgstr "Einladungsdetails anzeigen"
#: snikket_web/templates/admin_invites.html:38
#: snikket_web/templates/admin_invites.html:47
msgid "Copy invite link to clipboard"
msgstr "Einladungslink kopieren"
#: snikket_web/templates/admin_invites.html:40
#: snikket_web/templates/admin_invites.html:50
msgid "Delete invitation"
msgstr "Einladung löschen"
#: snikket_web/templates/admin_invites.html:48
#: snikket_web/templates/admin_invites.html:58
msgid "Currently, there are no pending invitations."
msgstr "Derzeit gibt es keine ausstehenden Einladungen."
#: snikket_web/templates/admin_users.html:17
#: snikket_web/templates/admin_users.html:11
msgid "Phone number"
msgstr "Telefonnummer"
@@ -253,14 +443,21 @@ msgstr "Snikket Webportal"
msgid "A <a href=\"%(about_url)s\">Snikket</a> server"
msgstr "Ein <a href=\"%(about_url)s\">Snikket</a>-Server"
#: snikket_web/templates/library.j2:13
#: snikket_web/templates/copy-snippet.html:106
msgid "Copied to clipboard"
msgstr "Kopiert"
#: snikket_web/templates/copy-snippet.html:109
msgid "Copy operation failed"
msgstr "Kopieren fehlgeschlagen"
#: snikket_web/templates/library.j2:18
msgid "Copy link"
msgstr "Link kopieren"
#: snikket_web/templates/library.j2:15
#, python-format
msgid "Copy &quot;%(content)s&quot; to clipboard"
msgstr "&quot;%(content)s&quot; kopieren"
#: snikket_web/templates/library.j2:79
msgid "Invalid input"
msgstr "Ungültige Eingabe"
#: snikket_web/templates/login.html:5
msgid "Snikket Login"
@@ -274,10 +471,6 @@ msgstr "Gib deine Snikket-Adresse und -Passwort ein um dein Konto zu verwalten."
msgid "Login failed"
msgstr "Anmeldung fehlgeschlagen"
#: snikket_web/templates/login.html:31
msgid "Log in"
msgstr "Anmelden"
#: snikket_web/templates/user_home.html:3
msgid "Welcome!"
msgstr "Willkommen!"
@@ -300,7 +493,7 @@ msgstr ""
"einsehen kann."
#: snikket_web/templates/user_home.html:11
#: snikket_web/templates/user_passwd.html:40
#: snikket_web/templates/user_passwd.html:42
msgid "Change password"
msgstr "Passwort ändern"
@@ -322,11 +515,11 @@ msgstr ""
"Verlasse das Snikket Web-Portal, ohne dass deine anderen Geräte "
"beeinträchtigt werden."
#: snikket_web/templates/user_logout.html:7
#: snikket_web/templates/user_logout.html:8
msgid "Sign out of the Snikket Web Portal"
msgstr "Aus dem Webportal abmelden"
#: snikket_web/templates/user_logout.html:8
#: snikket_web/templates/user_logout.html:9
msgid ""
"Click below to log yourself out of the web portal. This does not affect "
"any other connected devices."
@@ -334,15 +527,11 @@ msgstr ""
"Klicke unten um dich aus dem Webportal abzumelden. Dies betrifft keine "
"anderen Geräte von dir."
#: snikket_web/templates/user_logout.html:14
msgid "Sign out"
msgstr "Abmelden"
#: snikket_web/templates/user_passwd.html:7
#: snikket_web/templates/user_passwd.html:8
msgid "Change your password"
msgstr "Ändere dein Passwort"
#: snikket_web/templates/user_passwd.html:8
#: snikket_web/templates/user_passwd.html:9
msgid ""
"To change your password, you need to provide the current password as well"
" as the new one. To reduce the chance of typos, we ask for your new "
@@ -352,15 +541,15 @@ msgstr ""
"alsauch dein neues Passwort angeben. Damit Tippfehler dich nicht "
"aussperren bitten wir dich, dein neues Passwort zweimal einzutippen."
#: snikket_web/templates/user_passwd.html:12
#: snikket_web/templates/user_passwd.html:13
msgid "Password change failed"
msgstr "Passwortänderung fehlgeschlagen"
#: snikket_web/templates/user_passwd.html:35
#: snikket_web/templates/user_passwd.html:36
msgid "Warning"
msgstr "Warnung"
#: snikket_web/templates/user_passwd.html:36
#: snikket_web/templates/user_passwd.html:37
msgid ""
"After changing your password, you will have to enter the new password on "
"all of your devices."
@@ -368,15 +557,15 @@ msgstr ""
"Nachdem du das Passwort geändert hast, musst du das neue Passwort auf "
"allen Geräten manuell eintragen."
#: snikket_web/templates/user_profile.html:7
#: snikket_web/templates/user_profile.html:8
msgid "Profile"
msgstr "Profil"
#: snikket_web/templates/user_profile.html:17
#: snikket_web/templates/user_profile.html:18
msgid "Visibility"
msgstr "Sichtbarkeit"
#: snikket_web/templates/user_profile.html:18
#: snikket_web/templates/user_profile.html:19
msgid ""
"This section allows you to control who can see your profile information, "
"like avatar and nickname."
@@ -384,7 +573,3 @@ msgstr ""
"Hier kannst du einstellen, wer deine Profilinformationen, wie Bild oder "
"Anzeigename einsehen kann."
#: snikket_web/templates/user_profile.html:24
msgid "Apply"
msgstr "Übernehmen"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-01-17 20:13+0100\n"
"POT-Creation-Date: 2021-01-21 16:54+0100\n"
"PO-Revision-Date: 2020-03-07 16:50+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
@@ -18,18 +18,74 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n"
#: snikket_web/admin.py:44
#: snikket_web/admin.py:54
msgid "Delete user permanently"
msgstr ""
#: snikket_web/admin.py:71
#: snikket_web/admin.py:83
msgid "Invite to circle"
msgstr ""
#: snikket_web/admin.py:89
msgid "At least one circle must be selected"
msgstr ""
#: snikket_web/admin.py:94
msgid "Valid for"
msgstr ""
#: snikket_web/admin.py:96
msgid "One hour"
msgstr ""
#: snikket_web/admin.py:97
msgid "Twelve hours"
msgstr ""
#: snikket_web/admin.py:98
msgid "One day"
msgstr ""
#: snikket_web/admin.py:99
msgid "One week"
msgstr ""
#: snikket_web/admin.py:100
msgid "Four weeks"
msgstr ""
#: snikket_web/admin.py:106
msgid "Allow multiple uses"
msgstr ""
#: snikket_web/admin.py:110
msgid "New invitation link"
msgstr ""
#: snikket_web/admin.py:104
#: snikket_web/admin.py:170
msgid "Revoke"
msgstr ""
#: snikket_web/admin.py:225 snikket_web/admin.py:272
msgid "Name"
msgstr ""
#: snikket_web/admin.py:229 snikket_web/templates/admin_circles.html:42
msgid "Create circle"
msgstr ""
#: snikket_web/admin.py:276 snikket_web/user.py:73
msgid "Apply"
msgstr ""
#: snikket_web/admin.py:280
msgid "Delete circle permanently"
msgstr ""
#: snikket_web/infra.py:39
msgid "Main"
msgstr ""
#: snikket_web/main.py:33
msgid "Address"
msgstr ""
@@ -38,7 +94,11 @@ msgstr ""
msgid "Password"
msgstr ""
#: snikket_web/main.py:60
#: snikket_web/main.py:43
msgid "Sign in"
msgstr ""
#: snikket_web/main.py:64
#, fuzzy
msgid "Invalid user name or password."
msgstr ""
@@ -59,38 +119,100 @@ msgstr ""
msgid "The new passwords must match."
msgstr ""
#: snikket_web/user.py:50
msgid "Nobody"
msgstr ""
#: snikket_web/user.py:51
msgid "Friends only"
#: snikket_web/user.py:47
#, fuzzy
msgid "Sign out"
msgstr ""
#: snikket_web/user.py:52
msgid "Nobody"
msgstr ""
#: snikket_web/user.py:53
msgid "Friends only"
msgstr ""
#: snikket_web/user.py:54
msgid "Everyone"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:12
#: snikket_web/templates/admin_delete_user.html:16
#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:58
#: snikket_web/templates/admin_users.html:9 snikket_web/user.py:60
msgid "Display name"
msgstr ""
#: snikket_web/user.py:62
#: snikket_web/user.py:64
msgid "Avatar"
msgstr ""
#: snikket_web/user.py:66
#: snikket_web/user.py:68
msgid "Profile visibility"
msgstr ""
#: snikket_web/user.py:91
#: snikket_web/user.py:97
msgid "Incorrect password"
msgstr ""
#: snikket_web/templates/admin_circles.html:4
#: snikket_web/templates/admin_home.html:11
msgid "Manage circles"
msgstr ""
#: snikket_web/templates/admin_circles.html:11
msgid "Circle name"
msgstr ""
#: snikket_web/templates/admin_circles.html:12
msgid "Members"
msgstr ""
#: snikket_web/templates/admin_circles.html:13
#: snikket_web/templates/admin_invites.html:24
#: snikket_web/templates/admin_users.html:12
msgid "Actions"
msgstr ""
#: snikket_web/templates/admin_circles.html:23
#, python-format
msgid "Create invitation to circle %(circle_name)s"
msgstr ""
#: snikket_web/templates/admin_circles.html:26
#, python-format
msgid "Show details of circle %(circle_name)s"
msgstr ""
#: snikket_web/templates/admin_circles.html:35
msgid "No circles"
msgstr ""
#: snikket_web/templates/admin_circles.html:36
msgid ""
"Currently, there are no circles on this instance. Use the form below to "
"create one."
msgstr ""
#: snikket_web/templates/admin_circles.html:39
msgid "New circle"
msgstr ""
#: snikket_web/templates/admin_create_invite.html:3
msgid "Create invitation"
msgstr ""
#: snikket_web/templates/admin_create_invite_form.html:5
msgid "Create new invitation"
msgstr ""
#: snikket_web/templates/admin_create_invite_form.html:6
msgid ""
"Create a new invitation link to invite more users to your Snikket "
"instance by clicking the button below."
msgstr ""
#: snikket_web/templates/admin_delete_user.html:4
#: snikket_web/templates/admin_users.html:29
#: snikket_web/templates/admin_users.html:24
#, python-format
msgid "Delete user %(user_name)s"
msgstr ""
@@ -105,12 +227,12 @@ msgid "Are you sure you want to delete the following user?"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:10
#: snikket_web/templates/admin_users.html:14
#: snikket_web/templates/admin_users.html:8
msgid "Login name"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:14
#: snikket_web/templates/admin_users.html:16
#: snikket_web/templates/admin_users.html:10
#, fuzzy
msgid "Email address"
msgstr ""
@@ -119,29 +241,91 @@ msgstr ""
msgid "Danger"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:23
#: snikket_web/templates/admin_edit_circle.html:14
#: snikket_web/templates/admin_edit_invite.html:45
#: snikket_web/templates/user_logout.html:13
#: snikket_web/templates/user_passwd.html:40
#: snikket_web/templates/user_profile.html:25
msgid "Back"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:4
#, python-format
msgid "Edit circle %(circle_name)s"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:6
msgid "Circle information"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:18
msgid "Delete circle"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:19
msgid "Deleting a circle does not delete any users in the circle."
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:24
msgid "Circle members"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:38
#, python-format
msgid "Remove user %(username)s from circle"
msgstr ""
#: snikket_web/templates/admin_edit_circle.html:46
msgid "Invite more members"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:8
msgid "View invitation"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:13
#: snikket_web/templates/admin_invites.html:24
msgid "Created"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:15
#: snikket_web/templates/admin_invites.html:25
#: snikket_web/templates/admin_invites.html:21
msgid "Valid until"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:17
#: snikket_web/templates/admin_edit_invite.html:15
msgid "Link"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:24
#: snikket_web/templates/user_logout.html:12
#: snikket_web/templates/user_passwd.html:39
#: snikket_web/templates/user_profile.html:24
msgid "Back"
#: snikket_web/templates/admin_edit_invite.html:17
msgid "Reusability"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:18
msgid ""
"This invitation link can be used arbitrarily often, until it expires, is "
"revoked or a server-wide user limit is reached."
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:18
msgid "This invitation link can only be used once and is then depleted."
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:22
msgid "Circles"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:23
msgid "Users joining via this invitation will be added to the following circles:"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:29
#: snikket_web/templates/admin_invites.html:23
msgid "Circle"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:35
msgid "The user will not be added to any circle and will have no contacts."
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:39
msgid "Created"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:3
@@ -176,7 +360,7 @@ msgid "At your service, %(user_name)s."
msgstr ""
#: snikket_web/templates/admin_home.html:7
#: snikket_web/templates/admin_users.html:10
#: snikket_web/templates/admin_users.html:4
msgid "Manage users"
msgstr ""
@@ -184,59 +368,56 @@ msgstr ""
msgid "Modify administrative user information or delete users."
msgstr ""
#: snikket_web/templates/admin_home.html:11
#: snikket_web/templates/admin_invites.html:7
#: snikket_web/templates/admin_home.html:14
#: snikket_web/templates/admin_invites.html:8
msgid "Manage invitations"
msgstr ""
#: snikket_web/templates/admin_home.html:12
#: snikket_web/templates/admin_home.html:15
msgid "Create, revoke or view invitations."
msgstr ""
#: snikket_web/templates/admin_home.html:15
#: snikket_web/templates/admin_home.html:18
msgid "Back to the main view"
msgstr ""
#: snikket_web/templates/admin_home.html:16
#: snikket_web/templates/admin_home.html:19
msgid "Go back to your users web portal page."
msgstr ""
#: snikket_web/templates/admin_invites.html:10
msgid "Create new invitation"
msgstr ""
#: snikket_web/templates/admin_invites.html:11
msgid ""
"Create a new invitation link to invite more users to your Snikket "
"instance by clicking the button below."
msgstr ""
#: snikket_web/templates/admin_invites.html:16
msgid "Pending invitations"
msgstr ""
#: snikket_web/templates/admin_invites.html:26
#: snikket_web/templates/admin_users.html:18
msgid "Actions"
#: snikket_web/templates/admin_invites.html:22
msgid "Reusable"
msgstr ""
#: snikket_web/templates/admin_invites.html:36
#: snikket_web/templates/admin_invites.html:31
msgid "Yes"
msgstr ""
#: snikket_web/templates/admin_invites.html:31
msgid "No"
msgstr ""
#: snikket_web/templates/admin_invites.html:44
msgid "Show invite details"
msgstr ""
#: snikket_web/templates/admin_invites.html:38
#: snikket_web/templates/admin_invites.html:47
msgid "Copy invite link to clipboard"
msgstr ""
#: snikket_web/templates/admin_invites.html:40
#: snikket_web/templates/admin_invites.html:50
msgid "Delete invitation"
msgstr ""
#: snikket_web/templates/admin_invites.html:48
#: snikket_web/templates/admin_invites.html:58
msgid "Currently, there are no pending invitations."
msgstr ""
#: snikket_web/templates/admin_users.html:17
#: snikket_web/templates/admin_users.html:11
msgid "Phone number"
msgstr ""
@@ -249,13 +430,20 @@ msgstr ""
msgid "A <a href=\"%(about_url)s\">Snikket</a> server"
msgstr ""
#: snikket_web/templates/library.j2:13
#: snikket_web/templates/copy-snippet.html:106
msgid "Copied to clipboard"
msgstr ""
#: snikket_web/templates/copy-snippet.html:109
msgid "Copy operation failed"
msgstr ""
#: snikket_web/templates/library.j2:18
msgid "Copy link"
msgstr ""
#: snikket_web/templates/library.j2:15
#, python-format
msgid "Copy &quot;%(content)s&quot; to clipboard"
#: snikket_web/templates/library.j2:79
msgid "Invalid input"
msgstr ""
#: snikket_web/templates/login.html:5
@@ -270,10 +458,6 @@ msgstr ""
msgid "Login failed"
msgstr ""
#: snikket_web/templates/login.html:31
msgid "Log in"
msgstr ""
#: snikket_web/templates/user_home.html:3
msgid "Welcome!"
msgstr ""
@@ -294,7 +478,7 @@ msgid ""
msgstr ""
#: snikket_web/templates/user_home.html:11
#: snikket_web/templates/user_passwd.html:40
#: snikket_web/templates/user_passwd.html:42
msgid "Change password"
msgstr ""
@@ -314,60 +498,58 @@ msgstr ""
msgid "Exit the Snikket Web Portal, without logging out your other devices."
msgstr ""
#: snikket_web/templates/user_logout.html:7
#: snikket_web/templates/user_logout.html:8
msgid "Sign out of the Snikket Web Portal"
msgstr ""
#: snikket_web/templates/user_logout.html:8
#: snikket_web/templates/user_logout.html:9
msgid ""
"Click below to log yourself out of the web portal. This does not affect "
"any other connected devices."
msgstr ""
#: snikket_web/templates/user_logout.html:14
#, fuzzy
msgid "Sign out"
msgstr ""
#: snikket_web/templates/user_passwd.html:7
#: snikket_web/templates/user_passwd.html:8
msgid "Change your password"
msgstr ""
#: snikket_web/templates/user_passwd.html:8
#: snikket_web/templates/user_passwd.html:9
msgid ""
"To change your password, you need to provide the current password as well"
" as the new one. To reduce the chance of typos, we ask for your new "
"password twice."
msgstr ""
#: snikket_web/templates/user_passwd.html:12
#: snikket_web/templates/user_passwd.html:13
msgid "Password change failed"
msgstr ""
#: snikket_web/templates/user_passwd.html:35
#: snikket_web/templates/user_passwd.html:36
msgid "Warning"
msgstr ""
#: snikket_web/templates/user_passwd.html:36
#: snikket_web/templates/user_passwd.html:37
msgid ""
"After changing your password, you will have to enter the new password on "
"all of your devices."
msgstr ""
#: snikket_web/templates/user_profile.html:7
#: snikket_web/templates/user_profile.html:8
msgid "Profile"
msgstr ""
#: snikket_web/templates/user_profile.html:17
#: snikket_web/templates/user_profile.html:18
msgid "Visibility"
msgstr ""
#: snikket_web/templates/user_profile.html:18
#: snikket_web/templates/user_profile.html:19
msgid ""
"This section allows you to control who can see your profile information, "
"like avatar and nickname."
msgstr ""
#: snikket_web/templates/user_profile.html:24
msgid "Apply"
msgstr ""
#~ msgid "Copy &quot;%(content)s&quot; to clipboard"
#~ msgstr ""
#~ msgid "Log in"
#~ msgstr ""

View File

@@ -43,7 +43,9 @@ class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
class LogoutForm(flask_wtf.FlaskForm): # type:ignore
pass
action_signout = wtforms.SubmitField(
_l("Sign out"),
)
_ACCESS_MODEL_CHOICES = [
@@ -106,6 +108,9 @@ async def profile() -> typing.Union[str, quart.Response]:
form = ProfileForm()
if request.method != "POST":
user_info = await client.get_user_info()
# TODO: find a better way to determine the access model, e.g. by
# taking the first access model which is defined in [nickname, avatar,
# vcard] or by taking the most open one.-
try:
profile_access_model = await client.get_nickname_access_model()
except quart.exceptions.NotFound: