Implement admin dashboard

Fixes #23.
This commit is contained in:
Jonas Schäfer
2021-01-16 21:29:06 +01:00
parent 16bc3c6990
commit e476d9b7c2
19 changed files with 1186 additions and 178 deletions

View File

@@ -3,3 +3,4 @@ quart~=0.11
flask-wtf~=0.14 flask-wtf~=0.14
hsluv~=0.0.2 hsluv~=0.0.2
flask-babel~=1.0 flask-babel~=1.0
email-validator~=1.1

View File

@@ -17,6 +17,7 @@ import quart.exceptions
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
import wtforms import wtforms
import flask_babel
from flask_babel import Babel, _, lazy_gettext as _l from flask_babel import Babel, _, lazy_gettext as _l
from . import colour, xmpputil from . import colour, xmpputil
@@ -69,7 +70,7 @@ async def login() -> typing.Union[str, quart.Response]:
try: try:
await client.login(jid, password) await client.login(jid, password)
except quart.exceptions.Unauthorized: except quart.exceptions.Unauthorized:
form.errors.setdefault("", []).append( form.password.errors.append(
_("Invalid user name or password.") _("Invalid user name or password.")
) )
else: else:
@@ -166,6 +167,10 @@ def proc() -> typing.Dict[str, typing.Any]:
app.template_filter("repr")(repr) app.template_filter("repr")(repr)
app.template_filter("format_datetime")(flask_babel.format_datetime)
app.template_filter("format_date")(flask_babel.format_date)
app.template_filter("format_time")(flask_babel.format_time)
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
@app.template_filter("flatten") @app.template_filter("flatten")
@@ -175,8 +180,10 @@ def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
return a return a
from .user import user_bp # NOQA from .user import user_bp # noqa:F401,E402
from .admin import bp as admin_bp # noqa:F401,E402
app.register_blueprint(user_bp) app.register_blueprint(user_bp)
app.register_blueprint(admin_bp)
logging_config = app.config.get("LOGGING_CONFIG") logging_config = app.config.get("LOGGING_CONFIG")
if logging_config is not None: if logging_config is not None:

View File

@@ -0,0 +1,127 @@
import typing
from datetime import datetime
import quart.flask_patch
import wtforms
import wtforms.fields.html5
from quart import (Blueprint, render_template, redirect, url_for)
import flask_wtf
from flask_babel import lazy_gettext as _l
from snikket_web.prosodyclient import client
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/")
@client.require_admin_session()
async def index() -> str:
user_info = await client.get_user_info()
return await render_template("admin_home.html", user_info=user_info)
@bp.route("/users")
@client.require_admin_session()
async def users() -> str:
user_info = await client.get_user_info()
users = sorted(
await client.list_users(),
key=lambda x: x.localpart
)
return await render_template(
"admin_users.html",
users=users,
user_info=user_info,
)
class DeleteUserForm(flask_wtf.FlaskForm):
action_delete = wtforms.SubmitField(
_l("Delete user permanently")
)
@bp.route("/user/<localpart>/delete", methods=["GET", "POST"])
@client.require_admin_session()
async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
user_info = await client.get_user_info()
target_user_info = await client.get_user_by_localpart(localpart)
form = DeleteUserForm()
if form.validate_on_submit():
if form.action_delete.data:
await client.delete_user_by_localpart(localpart)
return redirect(url_for(".users"))
return await render_template(
"admin_delete_user.html",
target_user=target_user_info,
user_info=user_info,
form=form,
)
class InvitesListForm(flask_wtf.FlaskForm):
action_revoke = wtforms.StringField()
action_create_invite = wtforms.SubmitField(
_l("New invitation link")
)
@bp.route("/invitations", methods=["GET", "POST"])
@client.require_admin_session()
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
)
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,
now=datetime.utcnow(),
form=form,
)
class InviteForm(flask_wtf.FlaskForm):
action_revoke = wtforms.SubmitField(
_l("Revoke")
)
@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_)
form = InviteForm()
if form.validate_on_submit():
if form.action_revoke.data:
await client.delete_invite(id_)
return redirect(url_for(".invitations"))
return redirect(url_for(".edit_invite", id_=id_))
return await render_template(
"admin_edit_invite.html",
user_info=user_info,
invite=invite_info,
now=datetime.utcnow(),
form=form,
)

View File

@@ -1,3 +1,5 @@
import dataclasses
import enum
import functools import functools
import hashlib import hashlib
import logging import logging
@@ -5,6 +7,8 @@ import secrets
import types import types
import typing import typing
from datetime import datetime
import aiohttp import aiohttp
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -19,9 +23,71 @@ from . import xmpputil
from .xmpputil import split_jid from .xmpputil import split_jid
SCOPE_DEFAULT = "prosody:scope:default"
SCOPE_ADMIN = "prosody:scope:admin"
T = typing.TypeVar("T") T = typing.TypeVar("T")
@dataclasses.dataclass(frozen=True)
class TokenInfo:
token: str
scopes: typing.Collection[str]
@dataclasses.dataclass(frozen=True)
class AdminUserInfo:
localpart: str
display_name: typing.Optional[str]
email: typing.Optional[str]
phone: typing.Optional[str]
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminUserInfo":
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,
)
class InviteType(enum.Enum):
REGISTER = "register"
@dataclasses.dataclass(frozen=True)
class AdminInviteInfo:
id_: str
type_: InviteType
jid: typing.Optional[str]
token: str
xmpp_uri: typing.Optional[str]
landing_page: typing.Optional[str]
created_at: datetime
expires: datetime
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminInviteInfo":
return cls(
id_=data["id"],
created_at=datetime.utcfromtimestamp(data["created_at"]),
expires=datetime.utcfromtimestamp(data["expires"]),
type_=InviteType(data["type"]),
jid=data["jid"],
token=data["id"],
xmpp_uri=data.get("xmpp_uri"),
landing_page=data.get("landing_page"),
)
class HTTPSessionManager: class HTTPSessionManager:
def __init__(self, app_context_attribute: str): def __init__(self, app_context_attribute: str):
self._app_context_attribute = app_context_attribute self._app_context_attribute = app_context_attribute
@@ -119,6 +185,7 @@ class ProsodyClient:
CTX_AUTH_SESSION = "_ProsodyClient__auth_session" CTX_AUTH_SESSION = "_ProsodyClient__auth_session"
CONFIG_ENDPOINT = "PROSODY_ENDPOINT" CONFIG_ENDPOINT = "PROSODY_ENDPOINT"
SESSION_TOKEN = "prosody_access_token" SESSION_TOKEN = "prosody_access_token"
SESSION_CACHED_SCOPE = "prosody_scope_cache"
SESSION_ADDRESS = "prosody_jid" SESSION_ADDRESS = "prosody_jid"
def __init__(self, app: typing.Optional[quart.Quart] = None): def __init__(self, app: typing.Optional[quart.Quart] = None):
@@ -158,19 +225,26 @@ class ProsodyClient:
def _rest_endpoint(self) -> str: def _rest_endpoint(self) -> str:
return "{}/rest".format(self._endpoint_base) return "{}/rest".format(self._endpoint_base)
def _admin_v1_endpoint(self, subpath: str) -> str:
return "{}/admin_api{}".format(self._endpoint_base, subpath)
async def _oauth2_bearer_token(self, async def _oauth2_bearer_token(self,
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
jid: str, jid: str,
password: str) -> None: password: str) -> TokenInfo:
request = aiohttp.FormData() request = aiohttp.FormData()
request.add_field("grant_type", "password") request.add_field("grant_type", "password")
request.add_field("username", jid) request.add_field("username", jid)
request.add_field("password", password) request.add_field("password", password)
request.add_field(
"scope",
" ".join([SCOPE_DEFAULT, SCOPE_ADMIN])
)
self.logger.debug("sending OAuth2 request (payload omitted)") self.logger.debug("sending OAuth2 request (payload omitted)")
async with session.post(self._login_endpoint, data=request) as resp: async with session.post(self._login_endpoint, data=request) as resp:
auth_status = resp.status auth_status = resp.status
auth_info = (await resp.json()) auth_info: typing.Mapping[str, str] = (await resp.json())
if auth_status in [400, 401]: if auth_status in [400, 401]:
self.logger.debug("oauth2 error: %r", auth_info) self.logger.debug("oauth2 error: %r", auth_info)
@@ -189,7 +263,10 @@ class ProsodyClient:
auth_info["token_type"] auth_info["token_type"]
) )
) )
return auth_info["access_token"] return TokenInfo(
token=auth_info["access_token"],
scopes=auth_info["scope"].split(),
)
raise RuntimeError( raise RuntimeError(
"unexpected authentication reply: ({}) {!r}".format( "unexpected authentication reply: ({}) {!r}".format(
@@ -199,10 +276,13 @@ class ProsodyClient:
async def login(self, jid: str, password: str) -> bool: async def login(self, jid: str, password: str) -> bool:
async with self._plain_session as session: async with self._plain_session as session:
token = await self._oauth2_bearer_token(session, jid, password) token_info = await self._oauth2_bearer_token(
session, jid, password,
)
http_session[self.SESSION_TOKEN] = token http_session[self.SESSION_TOKEN] = token_info.token
http_session[self.SESSION_ADDRESS] = jid http_session[self.SESSION_ADDRESS] = jid
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
return True return True
@property @property
@@ -252,6 +332,30 @@ class ProsodyClient:
return wrapped return wrapped
return decorator return decorator
def require_admin_session(
self,
redirect_to: typing.Optional[str] = None,
) -> typing.Callable[
[typing.Callable[..., typing.Awaitable[T]]],
typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]]:
def decorator(
f: typing.Callable[..., typing.Awaitable[T]],
) -> typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]:
@functools.wraps(f)
@self.require_session(redirect_to=redirect_to)
async def wrapped(
*args: typing.Any,
**kwargs: typing.Any,
) -> typing.Union[T, quart.Response]:
if not self.is_admin_session:
raise abort(403, "This is not for you.")
return await f(*args, **kwargs)
return wrapped
return decorator
async def _xml_iq_call( async def _xml_iq_call(
self, self,
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
@@ -309,6 +413,7 @@ class ProsodyClient:
"nickname": nickname, "nickname": nickname,
"display_name": nickname or localpart, "display_name": nickname or localpart,
"avatar_hash": avatar_hash, "avatar_hash": avatar_hash,
"is_admin": self.is_admin_session,
} }
@autosession @autosession
@@ -450,12 +555,115 @@ class ProsodyClient:
# server to expire/revoke all tokens on password change. # server to expire/revoke all tokens on password change.
http_session[self.SESSION_TOKEN] = token http_session[self.SESSION_TOKEN] = token
def _raise_error_from_response(
self,
resp: aiohttp.ClientResponse,
) -> None:
if resp.status in [401, 403]:
abort(403, "request rejected by backend")
if resp.status == 400:
abort(500, "request rejected by backend")
if not 200 <= resp.status < 300:
abort(resp.status)
@autosession
async def list_users(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Collection[AdminUserInfo]:
result = []
async with session.get(self._admin_v1_endpoint("/users")) as resp:
self._raise_error_from_response(resp)
for user in await resp.json():
result.append(AdminUserInfo.from_api_response(user))
return result
@autosession
async def get_user_by_localpart(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> AdminUserInfo:
async with session.get(
self._admin_v1_endpoint("/users/{}".format(localpart)),
) as resp:
self._raise_error_from_response(resp)
return AdminUserInfo.from_api_response(await resp.json())
@autosession
async def delete_user_by_localpart(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint("/users/{}".format(localpart)),
) as resp:
self._raise_error_from_response(resp)
@autosession
async def list_invites(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Collection[AdminInviteInfo]:
async with session.get(self._admin_v1_endpoint("/invites")) as resp:
self._raise_error_from_response(resp)
return list(map(AdminInviteInfo.from_api_response,
await resp.json()))
@autosession
async def get_invite_by_id(
self,
id_: str,
*,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
async with session.get(
self._admin_v1_endpoint("/invites/{}".format(id_)),
) as resp:
self._raise_error_from_response(resp)
return AdminInviteInfo.from_api_response(await resp.json())
@autosession
async def delete_invite(
self,
id_: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint("/invites/{}".format(id_)),
) as resp:
self._raise_error_from_response(resp)
@autosession
async def create_invite(
self,
*,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
async with session.put(self._admin_v1_endpoint("/invites")) as resp:
self._raise_error_from_response(resp)
return AdminInviteInfo.from_api_response(await resp.json())
async def logout(self) -> None: async def logout(self) -> None:
# this currently only kills the cookie stuff, we may want to invalidate # this currently only kills the cookie stuff, we may want to invalidate
# the token on the server side, toos # the token on the server side, toos
# See-Also: https://issues.prosody.im/1503 # See-Also: https://issues.prosody.im/1503
http_session.pop(self.SESSION_TOKEN, None) http_session.pop(self.SESSION_TOKEN, None)
http_session.pop(self.SESSION_ADDRESS, None) http_session.pop(self.SESSION_ADDRESS, None)
http_session.pop(self.SESSION_CACHED_SCOPE, None)
@property
def is_admin_session(self) -> bool:
if not self.has_session:
return False
scopes = http_session[self.SESSION_CACHED_SCOPE].split()
return SCOPE_ADMIN in scopes
client = ProsodyClient() client = ProsodyClient()

View File

@@ -7,7 +7,7 @@ body {
color: $gray-100; color: $gray-100;
} }
p, blockquote, ul, ol { p, blockquote, ul, ol, table, dl {
line-height: 1.5; line-height: 1.5;
margin: 1.5em 0; margin: 1.5em 0;
font-family: $font-bulk; font-family: $font-bulk;
@@ -19,6 +19,10 @@ blockquote {
margin-right: $w-l2; margin-right: $w-l2;
} }
dt {
font-weight: bold;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
/* normalise */ /* normalise */
font-weight: 400; font-weight: 400;

View File

@@ -59,7 +59,7 @@ $colours: (
$box-types: ( $box-types: (
"primary": "blue", "primary": "blue",
"accent": "yellow", "accent": "yellow",
"alert": "alert", "alert": "red",
"warning": "yellow", "warning": "yellow",
"success": "green", "success": "green",
"hint": "blue" "hint": "blue"

View File

@@ -186,7 +186,7 @@ p.form-desc.weak {
color: $gray-300; color: $gray-300;
} }
$text-entry-inputs: "text" "password"; $text-entry-inputs: "text" "password" "email" "tel";
div.f-errbox { div.f-errbox {
background-color: $alert-800; background-color: $alert-800;
@@ -390,9 +390,30 @@ div.form.layout-expanded {
/* form buttons */ /* form buttons */
button, .button { .btn-edit:before {
content: '';
}
.btn-delete:before {
content: '🗑';
}
.btn-link:before {
content: '+';
}
.btn-more:before {
content: '';
}
input[type="submit"], button, .button {
margin: 0 $w-s2; margin: 0 $w-s2;
padding: $w-s3 $w-s1; padding: $w-s3 $w-s1;
td & {
margin: 0 $w-s4;
padding: $w-s4 $w-s2;
}
} }
a.button { a.button {
@@ -400,141 +421,145 @@ a.button {
cursor: default; cursor: default;
} }
button.primary, .button.primary { input[type="submit"], button, .button {
background: linear-gradient(0deg, $primary-500, $primary-600); &.primary {
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); background: linear-gradient(0deg, $primary-500, $primary-600);
color: $primary-900; box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1);
border: none; color: $primary-900;
/* TODO: fix vertical rhyhtm ... */ border: none;
border-radius: $w-s4; /* TODO: fix vertical rhyhtm ... */
// border: $w-s4 solid transparent; border-radius: $w-s4;
// border: $w-s4 solid transparent;
&:hover, &:focus {
background: linear-gradient(0deg, $primary-600, $primary-700);
color: white;
}
&:active {
background: $primary-500;
box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2);
color: white;
}
&.accent {
background: linear-gradient(0deg, $accent-500, $accent-600);
color: $accent-900;
&:hover, &:focus { &:hover, &:focus {
background: linear-gradient(0deg, $primary-600, $primary-700);
color: white;
}
&:active {
background: $primary-500;
box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2);
color: white;
}
&.accent {
background: linear-gradient(0deg, $accent-500, $accent-600);
color: $accent-900;
&:hover, &:focus {
background: linear-gradient(0deg, $accent-600, $accent-700);
}
&:active {
background: $accent-500;
}
}
&.danger {
background: linear-gradient(0deg, $alert-500, $alert-600);
color: $alert-900;
&:hover, &:focus {
background: linear-gradient(0deg, $alert-600, $alert-700);
}
&:active {
background: $alert-500;
}
}
}
&.secondary {
background: linear-gradient(0deg, $gray-600, $gray-700);
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1);
color: $gray-200;
border: none;
/* TODO: fix vertical rhyhtm ... */
border-radius: $w-s4;
// border: $w-s4 solid transparent;
&:hover, &:focus {
background: linear-gradient(0deg, $gray-700, $gray-800);
color: black;
}
&:active {
background: $gray-600;
box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2);
color: black;
}
&.accent {
background: linear-gradient(0deg, $accent-600, $accent-700); background: linear-gradient(0deg, $accent-600, $accent-700);
color: $accent-200;
&:hover, &:focus {
background: linear-gradient(0deg, $accent-700, $accent-800);
}
&:active {
background: $accent-600;
}
} }
&:active { &.danger {
background: $accent-500;
}
}
&.danger {
background: linear-gradient(0deg, $alert-500, $alert-600);
color: $alert-900;
&:hover, &:focus {
background: linear-gradient(0deg, $alert-600, $alert-700); background: linear-gradient(0deg, $alert-600, $alert-700);
} color: $alert-200;
&:active { &:hover, &:focus {
background: $alert-500; background: linear-gradient(0deg, $alert-700, $alert-800);
} }
}
}
button.secondary, .button.secondary { &:active {
background: linear-gradient(0deg, $gray-600, $gray-700); background: $alert-600;
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); }
color: $gray-200;
border: none;
/* TODO: fix vertical rhyhtm ... */
border-radius: $w-s4;
// border: $w-s4 solid transparent;
&:hover, &:focus {
background: linear-gradient(0deg, $gray-700, $gray-800);
color: black;
}
&:active {
background: $gray-600;
box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.2);
color: black;
}
&.accent {
background: linear-gradient(0deg, $accent-600, $accent-700);
color: $accent-200;
&:hover, &:focus {
background: linear-gradient(0deg, $accent-700, $accent-800);
}
&:active {
background: $accent-600;
} }
} }
&.danger { &.tertiary, .tertiary {
background: linear-gradient(0deg, $alert-600, $alert-700); background-color: transparent;
color: $alert-200; color: $gray-100;
border: none;
&:hover, &:focus { text-decoration: underline;
background: linear-gradient(0deg, $alert-700, $alert-800); /* TODO: fix vertical rhyhtm ... */
} border-radius: $w-s4;
&:active {
background: $alert-600;
}
}
}
button.tertiary, .button.tertiary {
background-color: transparent;
color: $gray-100;
border: none;
text-decoration: underline;
/* TODO: fix vertical rhyhtm ... */
border-radius: $w-s4;
&:hover {
background-color: $gray-900;
border-color: $gray-800;
color: black;
}
&.accent {
text-decoration-color: $accent-500;
&:hover { &:hover {
background-color: $accent-900; background-color: $gray-900;
border-color: $accent-800; border-color: $gray-800;
color: black; color: black;
} }
&.accent {
text-decoration-color: $accent-500;
&:hover {
background-color: $accent-900;
border-color: $accent-800;
color: black;
}
}
&.danger {
text-decoration-color: $alert-500;
&:hover {
background-color: $alert-900;
border-color: $alert-800;
color: black;
}
}
} }
&.danger { &.fullwidth {
text-decoration-color: $alert-500; display: block;
width: 100%;
&:hover { margin-left: 0;
background-color: $alert-900; margin-right: 0;
border-color: $alert-800;
color: black;
}
} }
} }
button.fullwidth, .button.fullwidth {
display: block;
width: 100%;
margin-left: 0;
margin-right: 0;
}
/* button, .button { /* button, .button {
margin: 0 $w-s2; margin: 0 $w-s2;
@@ -710,7 +735,7 @@ div.welcome-cards {
flex-wrap: wrap; flex-wrap: wrap;
& > .card { & > .card {
flex: 1 0 $w-l6; flex: 1 0 $w-l7;
margin: $w-s1; margin: $w-s1;
@extend .el-2; @extend .el-2;
padding: $w-s1 $w-l1; padding: $w-s1 $w-l1;
@@ -733,6 +758,24 @@ div.welcome-cards {
} }
/* admin area specials */
#topbar > div.admin-note {
color: $alert-500;
font-size: nth($h-sizes, 5);
margin-left: $w-l1;
}
table {
border-collapse: collapse;
width: 100%;
td, th {
padding: $w-s1;
}
}
/* linearisation / responsive stuff */ /* linearisation / responsive stuff */
@media screen and (max-width: $small-screen-threshold) { @media screen and (max-width: $small-screen-threshold) {
@@ -751,4 +794,31 @@ div.welcome-cards {
} }
th.collapsible, td.collapsible {
display: none;
}
#topbar.admin {
> header {
text-decoration: underline;
text-decoration-color: $alert-500;
}
> div.admin-note {
display: none;
}
}
}
/* clipboard button */
.copy-to-clipboard {
cursor: pointer;
margin-left: 0.5em;
font-style: normal;
text-decoration: none;
}
body.no-copy .copy-to-clipboard {
display: none !important;
} }

View File

@@ -0,0 +1,6 @@
{% extends "app.html" %}
{% block topbar_classes %}{{ super() }} admin{% endblock %}
{% block topbar_left %}
{{ super() }}
<div class="admin-note">Admin area</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "admin_app.html" %}
{% from "library.j2" import box %}
{% block content %}
<h1>{% trans user_name=target_user.localpart %}Delete user {{ user_name }}{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}Delete user{% endtrans %}</h2>
{{ form.csrf_token }}
<p class="form-descr">{% trans %}Are you sure you want to delete the following user?{% endtrans %}</p>
<dl>
<dt>{% trans %}Login name{% endtrans %}</dt>
<dd>{{ target_user.localpart }}</dd>
<dt>{% trans %}Display name{% endtrans %}</dt>
<dd>{{ target_user.display_name }}</dd>
<dt>{% trans %}Email address{% endtrans %}</dt>
<dd>{{ target_user.email }}</dd>
<dt>{% trans %}Display name{% endtrans %}</dt>
<dd>{{ target_user.phone }}</dd>
</dl>
{% call box("alert", _("Danger")) %}
<p>The user and their data will be deleted irrevocably, permanently and immediately upon pushing thre below button. <strong>There is no way back!</strong></p>
{% endcall %}
<div class="f-bbox">
<a class="button secondary" href="{{ url_for('.users') }}">Back</a>
{{ form.action_delete(class="primary danger") }}
</div>
</form></div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "admin_app.html" %}
{% block head_lead %}
{{ super() }}
{% include "copy-snippet.html" %}
{% endblock %}
{% macro clipboard_button(caller=None) -%}
{%- set text = caller() -%}
<a title="Copy &quot;{{ text }}&quot; to clipboard" aria-label="Copy &quot;{{ text }}&quot; to clipboard" class="copy-to-clipboard" onclick="copy_to_clipboard(this); return false;" data-cliptext="{{ text }}" href="#">📋</a>
{%- endmacro %}
{% macro showuri(uri, caller=None) %}
{%- if uri is none -%}
<em></em>
{%- else -%}
<a href="{{ uri }}" target="_blank">{{ uri }}</a> {% call clipboard_button() %}{{ uri }}{% endcall %}
{%- endif -%}
{% endmacro %}
{% block content %}
<h1>{% trans %}View invitation{% endtrans %}</h1>
<form method="POST">
{{ form.csrf_token }}
<div class="form layout-expanded">
<dl>
<dt>Created</dt>
<dd>{{ invite.created_at | format_date }}</dd>
<dt>Valid until</dt>
<dd>{{ invite.expires | format_date }}</dd>
<dt>Landing page</dt>
<dd>{% call showuri(invite.landing_page) %}{% endcall %}</dd>
<dt>XMPP URI</dt>
<dd>{% call showuri(invite.xmpp_uri) %}{% endcall %}</dd>
</dl>
<div class="f-bbox">
{#- -#}
{{ form.action_revoke(class="button secondary danger") }}
{#- -#}
<a href="{{ url_for(".invitations") }}" class="button primary">{% trans %}Back{% endtrans %}</a>
{#- -#}
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "admin_app.html" %}
{% block content %}
<h1>{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}</h1>
<div class="form layout-expanded"><form method="POST">
<h2 class="form-title">{% trans %}User information{% endtrans %}</h2>
{{ form.csrf_token }}
<div class="f-ebox">
{{ form.username.label }}
{{ form.username(readonly="readonly") }}
</div>
<div class="f-ebox">
{{ form.nickname.label }}
{{ form.nickname(readonly="readonly") }}
</div>
<div class="f-ebox">
{{ form.email.label }}
{{ form.email(readonly="readonly") }}
</div>
<div class="f-ebox">
{{ form.phone.label }}
{{ form.phone(readonly="readonly") }}
</div>
{{ form.action_save(class="primary") }}
<input type="submit" class="a11y-only">
<h2 class="form-title">{% trans %}Password reset{% endtrans %}</h2>
<p>{% trans %}If the user has forgotten their password, use the below button to create a password reset link. The password reset link can be used once to change the password of the account. Transmit the link to the user via a secure channel.{% endtrans %}</p>
{{ form.action_create_reset_link(class="secondary accent") }}
<h2 class="form-title">{% trans %}Delete user{% endtrans %}</h2>
<p>{% trans %}{% endtrans %}</p>
{{ form.action_create_reset_link(class="secondary accent") }}
</form></div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "admin_app.html" %}
{% block content %}
<h1>{% trans %}Welcome to the administration dashboard!{% endtrans %}</h1>
<p>{% trans user_name=user_info.display_name %}At your service, {{ user_name }}.{% endtrans %}</p>
<div class="welcome-cards">
<a class="card" href="{{ url_for('.users') }}">
<h2>{% trans %}Manage users{% endtrans %}</h2>
<p>{% trans %}Modify administrative user information or delete users.{% endtrans %}</p>
</a>
<a class="card" href="{{ url_for('.invitations') }}">
<h2>{% trans %}Manage invitations{% endtrans %}</h2>
<p>{% trans %}Create, revoke or view invitations.{% endtrans %}</p>
</a>
<a class="card" href="{{ url_for('user.index') }}">
<h2>{% trans %}Back to the main view{% endtrans %}</h2>
<p>{% trans %}Go back to your users web portal page.{% endtrans %}</p>
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "admin_app.html" %}
{% 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">
{{ form.action_create_invite(class="primary") }}
</div>
</div>
<h2>{% trans %}Pending invitations{% endtrans %}</h2>
{% if invites %}
<table>
<thead>
<tr>
<th>Created</th>
<th>Expires</th>
<th>Actions</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>
{#- -#}
<a href="{{ url_for(".edit_invite", id_=invite.id_) }}" class="button primary btn-more" title="{% trans %}Show invite details{% endtrans %}"><span class="a11y-only">{% trans %}Show invite details{% endtrans %}</span></a>
{#- -#}
<button type="submit" class="secondary danger btn-delete" name="{{ form.action_revoke.name }}" value="{{ invite.id_ }}"><span class="a11y-only">{% trans %}Delete invitation{% endtrans %}</span></button>
{#- -#}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>{% trans %}Currently, there are no pending invitations.{% endtrans %}</p>
{% endif %}
</form>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "admin_app.html" %}
{% macro value_or_hint(v, caller=None) %}
{%- if v is not none -%}
{{- v -}}
{%- else -%}
{%- endif -%}
{% endmacro %}
{% block content %}
<h1>{% trans %}Manage users{% endtrans %}</h1>
<table>
<thead>
<tr>
<th>{% trans %}Login name{% endtrans %}</th>
<th>{% trans %}Display name{% endtrans %}</th>
<th class="collapsible">{% trans %}Email address{% endtrans %}</th>
<th class="collapsible">{% trans %}Phone number{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.localpart }}</td>
<td>{% call value_or_hint(user.display_name) %}{% endcall %}</td>
<td class="collapsible">{% call value_or_hint(user.email) %}{% endcall %}</td>
<td class="collapsible">{% call value_or_hint(user.phone) %}{% endcall %}</td>
<td>
{#- -#}<a class="button secondary btn-delete" href="{{ url_for(".delete_user", localpart=user.localpart) }}" title="{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}"><span class="a11y-only">{% trans user_name=user.localpart %}Delete user {{ user_name }}{% endtrans %}</span></a>
{#- -#}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -8,9 +8,11 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="topbar"> <div id="topbar" class="{% block topbar_classes %}{% endblock %}">
<header><a href="{{ url_for('user.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header> <header><a href="{{ url_for('.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header>
{% block topbar_left %}{% endblock %}
<div class="filler"></div> <div class="filler"></div>
{% block topbar_right %}{% endblock %}
<nav class="usermenu">{{ user_info.display_name }}{% call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall %}</nav> <nav class="usermenu">{{ user_info.display_name }}{% call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall %}</nav>
</div> </div>
<main>{% block content %}{% endblock %}</main> <main>{% block content %}{% endblock %}</main>

View File

@@ -0,0 +1,71 @@
<script async type="text/javascript">
/* https://stackoverflow.com/a/30810322/1248008 */
function fallbackCopyTextToClipboard(text, context) {
var textArea = document.createElement("textarea");
textArea.value = text;
context.appendChild(textArea);
textArea.focus();
textArea.select();
var result = false;
try {
var successful = document.execCommand('copy');
var msg = successful ? 'successful' : 'unsuccessful';
console.log('Fallback: Copying text command was ' + msg);
result = true;
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
context.removeChild(textArea);
return result;
}
function copyTextToClipboard(text, context, callback) {
if (!navigator.clipboard) {
callback(fallbackCopyTextToClipboard(text, context));
return;
}
navigator.clipboard.writeText(text).then(function() {
console.log('Async: Copying to clipboard was successful!');
callback(true);
}, function(err) {
console.error('Async: Could not copy text: ', err);
callback(false);
});
}
/* end of https://stackoverflow.com/a/30810322/1248008 */
var copy_to_clipboard = 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 result_el = document.createElement("span");
result_el.id = "clipboard-result";
if (success) {
result_el.classList.add("success");
result_el.innerText = "Copied!";
} else {
result_el.classList.add("error");
result_el.innerText = "Clipboard operation failed!";
}
el.appendChild(result_el);
setTimeout(function() {
el.removeChild(result_el);
el.blur();
}, 1500);
});
};
window.addEventListener('load', function() {
document.body.classList.remove("no-copy");
});
</script>

View File

@@ -10,6 +10,12 @@
<a class="card" href="{{ url_for('user.change_pw') }}"> <a class="card" href="{{ url_for('user.change_pw') }}">
<h2>{% trans %}Change password{% endtrans %}</h2> <h2>{% trans %}Change password{% endtrans %}</h2>
</a> </a>
{% if user_info.is_admin %}
<a class="card" href="{{ url_for('admin.index') }}">
<h2>{% trans %}Admin dashboard{% endtrans %}</h2>
<p>{% trans %}Manage users and invitations of this Snikket instance.{% endtrans %}</p>
</a>
{% endif %}
<a class="card" href="{{ url_for('user.logout') }}"> <a class="card" href="{{ url_for('user.logout') }}">
<h2>{% trans %}Log out{% endtrans %}</h2> <h2>{% trans %}Log out{% endtrans %}</h2>
<p>{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}</p> <p>{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}</p>

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: SnikketWeb 0.1.0\n" "Project-Id-Version: SnikketWeb 0.1.0\n"
"Report-Msgid-Bugs-To: jonas@zombofant.net\n" "Report-Msgid-Bugs-To: jonas@zombofant.net\n"
"POT-Creation-Date: 2021-01-16 14:53+0100\n" "POT-Creation-Date: 2021-01-17 20:09+0100\n"
"PO-Revision-Date: 2020-03-07 16:32+0100\n" "PO-Revision-Date: 2020-03-07 16:32+0100\n"
"Last-Translator: Jonas Schäfer <jonas@zombofant.net>\n" "Last-Translator: Jonas Schäfer <jonas@zombofant.net>\n"
"Language: de\n" "Language: de\n"
@@ -18,20 +18,173 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
#: snikket_web/__init__.py:38 #: snikket_web/__init__.py:40
msgid "Address" msgid "Address"
msgstr "Adresse" msgstr "Adresse"
#: snikket_web/__init__.py:43 #: snikket_web/__init__.py:45
msgid "Password" msgid "Password"
msgstr "Passwort" msgstr "Passwort"
#: snikket_web/__init__.py:72 #: snikket_web/__init__.py:74
#, fuzzy
msgid "Invalid user name or password." msgid "Invalid user name or password."
msgstr "Neues Passwort (Bestätigung)" msgstr "Benutzername oder Passwort falsch."
#: snikket_web/templates/app.html:18 snikket_web/templates/login.html:36 #: snikket_web/admin/__init__.py:44
msgid "Delete user permanently"
msgstr "Benutzer endgültig löschen"
#: snikket_web/admin/__init__.py:71
msgid "New invitation link"
msgstr "Neuer Einladungslink"
#: snikket_web/admin/__init__.py:104
msgid "Revoke"
msgstr "Löschen"
#: snikket_web/templates/admin_delete_user.html:4
#: snikket_web/templates/admin_users.html:29
#, python-format
msgid "Delete user %(user_name)s"
msgstr "Benutzer %(user_name)s löschen"
#: snikket_web/templates/admin_delete_user.html:6
#: snikket_web/templates/admin_edit_user.html:28
msgid "Delete user"
msgstr "Benutzer löschen"
#: snikket_web/templates/admin_delete_user.html:8
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
msgid "Login name"
msgstr "Anmeldename"
#: 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/__init__.py:48
msgid "Display name"
msgstr "Anzeigename"
#: snikket_web/templates/admin_delete_user.html:14
#: snikket_web/templates/admin_users.html:16
msgid "Email address"
msgstr "E-Mail-Adresse"
#: snikket_web/templates/admin_delete_user.html:19
msgid "Danger"
msgstr "Gefahr"
#: snikket_web/templates/admin_edit_invite.html:21
msgid "View invitation"
msgstr "Einladung anzeigen"
#: snikket_web/templates/admin_edit_invite.html:39
#: snikket_web/templates/user_passwd.html:39
#: snikket_web/templates/user_profile.html:18
msgid "Back"
msgstr "Zurück"
#: snikket_web/templates/admin_edit_user.html:3
#, python-format
msgid "Edit user %(user_name)s"
msgstr "Benutzer %(user_name)s bearbeiten"
#: snikket_web/templates/admin_edit_user.html:5
msgid "User information"
msgstr "Benutzerinformationen"
#: snikket_web/templates/admin_edit_user.html:25
msgid "Password reset"
msgstr "Passwort zurücksetzen"
#: snikket_web/templates/admin_edit_user.html:26
msgid ""
"If the user has forgotten their password, use the below button to create "
"a password reset link. The password reset link can be used once to change"
" the password of the account. Transmit the link to the user via a secure "
"channel."
msgstr ""
"Wenn ein Benutzer das Passwort vergessen hat, kannst du den folgenden "
"Button benutzen um einen Link zum Zurücksetzen des Passworts zu erzeugen."
" Dieser Link kann dann ein einziges Mal verwendet werden um das Passwort "
"des Kontos zu ändern. Lasse den Link dem Benutzer auf einem sicheren "
"Übertragungsweg zukommen."
#: snikket_web/templates/admin_home.html:3
msgid "Welcome to the administration dashboard!"
msgstr "Willkommen zum Verwaltungsdashboard!"
#: snikket_web/templates/admin_home.html:4
#, python-format
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
msgid "Manage users"
msgstr "Benutzer verwalten"
#: snikket_web/templates/admin_home.html:8
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:3
msgid "Manage invitations"
msgstr "Einladungen verwalten"
#: snikket_web/templates/admin_home.html:12
msgid "Create, revoke or view invitations."
msgstr "Einladungen erzeugen, löschen oder anzeigen."
#: snikket_web/templates/admin_home.html:15
msgid "Back to the main view"
msgstr "Zurück zur Hauptseite"
#: snikket_web/templates/admin_home.html:16
msgid "Go back to your users web portal page."
msgstr "Zurück zur Startseite deines Benutzers."
#: snikket_web/templates/admin_invites.html:6
msgid "Create new invitation"
msgstr "Neue Einladung erzeugen"
#: snikket_web/templates/admin_invites.html:7
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:12
msgid "Pending invitations"
msgstr "Ausstehende Einladungen"
#: snikket_web/templates/admin_invites.html:29
msgid "Show invite details"
msgstr "Einladungsdetails anzeigen"
#: snikket_web/templates/admin_invites.html:31
msgid "Delete invitation"
msgstr "Einladung löschen"
#: snikket_web/templates/admin_invites.html:39
msgid "Currently, there are no pending invitations."
msgstr "Derzeit gibt es keine ausstehenden Einladungen."
#: snikket_web/templates/admin_users.html:17
msgid "Phone number"
msgstr "Telefonnummer"
#: snikket_web/templates/admin_users.html:18
msgid "Actions"
msgstr "Aktionen"
#: snikket_web/templates/app.html:20 snikket_web/templates/login.html:36
#, python-format #, python-format
msgid "A <a href=\"%(about_url)s\">Snikket</a> server" msgid "A <a href=\"%(about_url)s\">Snikket</a> server"
msgstr "Ein <a href=\"%(about_url)s\">Snikket</a>-Server" msgstr "Ein <a href=\"%(about_url)s\">Snikket</a>-Server"
@@ -46,7 +199,7 @@ msgstr "Gib deine Snikket-Adresse und -Passwort ein um dein Konto zu verwalten."
#: snikket_web/templates/login.html:18 #: snikket_web/templates/login.html:18
msgid "Login failed" msgid "Login failed"
msgstr "" msgstr "Anmeldung fehlgeschlagen"
#: snikket_web/templates/login.html:31 #: snikket_web/templates/login.html:31
msgid "Log in" msgid "Log in"
@@ -78,11 +231,19 @@ msgstr ""
msgid "Change password" msgid "Change password"
msgstr "Passwort ändern" msgstr "Passwort ändern"
#: snikket_web/templates/user_home.html:14 #: snikket_web/templates/user_home.html:15
msgid "Admin dashboard"
msgstr "Verwaltungsdashboard"
#: snikket_web/templates/user_home.html:16
msgid "Manage users and invitations of this Snikket instance."
msgstr "Benutzer und Einladungen von dieser Snikket-Instanz verwalten."
#: snikket_web/templates/user_home.html:20
msgid "Log out" msgid "Log out"
msgstr "Abmelden" msgstr "Abmelden"
#: snikket_web/templates/user_home.html:15 #: snikket_web/templates/user_home.html:21
msgid "Exit the Snikket Web Portal, without logging out your other devices." msgid "Exit the Snikket Web Portal, without logging out your other devices."
msgstr "" msgstr ""
"Verlasse das Snikket Web-Portal, ohne dass deine anderen Geräte " "Verlasse das Snikket Web-Portal, ohne dass deine anderen Geräte "
@@ -115,13 +276,8 @@ msgid ""
"After changing your password, you will have to enter the new password on " "After changing your password, you will have to enter the new password on "
"all of your devices." "all of your devices."
msgstr "" msgstr ""
"Nachdem du das Passwort geändert hast, musst du das neue Passwort auf allen" "Nachdem du das Passwort geändert hast, musst du das neue Passwort auf "
"Geräten manuell eintragen." "allenGeräten manuell eintragen."
#: snikket_web/templates/user_passwd.html:39
#: snikket_web/templates/user_profile.html:18
msgid "Back"
msgstr "Zurück"
#: snikket_web/templates/user_profile.html:7 #: snikket_web/templates/user_profile.html:7
msgid "Profile" msgid "Profile"
@@ -131,31 +287,30 @@ msgstr "Profil"
msgid "Apply" msgid "Apply"
msgstr "Übernehmen" msgstr "Übernehmen"
#: snikket_web/user/__init__.py:21 #: snikket_web/user/__init__.py:23
msgid "Current password" msgid "Current password"
msgstr "Aktuelles Passwort" msgstr "Aktuelles Passwort"
#: snikket_web/user/__init__.py:26 #: snikket_web/user/__init__.py:28
msgid "New password" msgid "New password"
msgstr "Neues Passwort" msgstr "Neues Passwort"
#: snikket_web/user/__init__.py:31 #: snikket_web/user/__init__.py:33
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Neues Passwort (Bestätigung)" msgstr "Neues Passwort (Bestätigung)"
#: snikket_web/user/__init__.py:35 #: snikket_web/user/__init__.py:37
msgid "The new passwords must match." msgid "The new passwords must match."
msgstr "Die neuen Passwörter müssen übereinstimmen." msgstr "Die neuen Passwörter müssen übereinstimmen."
#: snikket_web/user/__init__.py:46 #: snikket_web/user/__init__.py:52
msgid "Display name"
msgstr "Anzeigename"
#: snikket_web/user/__init__.py:50
msgid "Avatar" msgid "Avatar"
msgstr "Bild" msgstr "Bild"
#: snikket_web/user/__init__.py:74 #: snikket_web/user/__init__.py:76
msgid "Incorrect password" msgid "Incorrect password"
msgstr "Ungültiges Passwort" msgstr "Ungültiges Passwort"
#~ msgid "none"
#~ msgstr "keiner"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-01-16 14:53+0100\n" "POT-Creation-Date: 2021-01-17 20:09+0100\n"
"PO-Revision-Date: 2020-03-07 16:50+0100\n" "PO-Revision-Date: 2020-03-07 16:50+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n" "Language: en\n"
@@ -18,20 +18,169 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
#: snikket_web/__init__.py:38 #: snikket_web/__init__.py:40
msgid "Address" msgid "Address"
msgstr "Address" msgstr "Address"
#: snikket_web/__init__.py:43 #: snikket_web/__init__.py:45
msgid "Password" msgid "Password"
msgstr "Password" msgstr "Password"
#: snikket_web/__init__.py:72 #: snikket_web/__init__.py:74
#, fuzzy #, fuzzy
msgid "Invalid user name or password." msgid "Invalid user name or password."
msgstr "Confirm new password" msgstr "Confirm new password"
#: snikket_web/templates/app.html:18 snikket_web/templates/login.html:36 #: snikket_web/admin/__init__.py:44
msgid "Delete user permanently"
msgstr ""
#: snikket_web/admin/__init__.py:71
msgid "New invitation link"
msgstr ""
#: snikket_web/admin/__init__.py:104
msgid "Revoke"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:4
#: snikket_web/templates/admin_users.html:29
#, fuzzy, python-format
msgid "Delete user %(user_name)s"
msgstr "Welcome home, %(user_name)s."
#: snikket_web/templates/admin_delete_user.html:6
#: snikket_web/templates/admin_edit_user.html:28
msgid "Delete user"
msgstr ""
#: snikket_web/templates/admin_delete_user.html:8
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
msgid "Login name"
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/__init__.py:48
msgid "Display name"
msgstr "Display name"
#: snikket_web/templates/admin_delete_user.html:14
#: snikket_web/templates/admin_users.html:16
#, fuzzy
msgid "Email address"
msgstr "Address"
#: snikket_web/templates/admin_delete_user.html:19
msgid "Danger"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:21
msgid "View invitation"
msgstr ""
#: snikket_web/templates/admin_edit_invite.html:39
#: snikket_web/templates/user_passwd.html:39
#: snikket_web/templates/user_profile.html:18
msgid "Back"
msgstr "Back"
#: snikket_web/templates/admin_edit_user.html:3
#, fuzzy, python-format
msgid "Edit user %(user_name)s"
msgstr "Welcome home, %(user_name)s."
#: snikket_web/templates/admin_edit_user.html:5
msgid "User information"
msgstr ""
#: snikket_web/templates/admin_edit_user.html:25
#, fuzzy
msgid "Password reset"
msgstr "Password"
#: snikket_web/templates/admin_edit_user.html:26
msgid ""
"If the user has forgotten their password, use the below button to create "
"a password reset link. The password reset link can be used once to change"
" the password of the account. Transmit the link to the user via a secure "
"channel."
msgstr ""
#: snikket_web/templates/admin_home.html:3
msgid "Welcome to the administration dashboard!"
msgstr ""
#: snikket_web/templates/admin_home.html:4
#, fuzzy, python-format
msgid "At your service, %(user_name)s."
msgstr "Welcome home, %(user_name)s."
#: snikket_web/templates/admin_home.html:7
#: snikket_web/templates/admin_users.html:10
msgid "Manage users"
msgstr ""
#: snikket_web/templates/admin_home.html:8
msgid "Modify administrative user information or delete users."
msgstr ""
#: snikket_web/templates/admin_home.html:11
#: snikket_web/templates/admin_invites.html:3
msgid "Manage invitations"
msgstr ""
#: snikket_web/templates/admin_home.html:12
msgid "Create, revoke or view invitations."
msgstr ""
#: snikket_web/templates/admin_home.html:15
msgid "Back to the main view"
msgstr ""
#: snikket_web/templates/admin_home.html:16
msgid "Go back to your users web portal page."
msgstr ""
#: snikket_web/templates/admin_invites.html:6
msgid "Create new invitation"
msgstr ""
#: snikket_web/templates/admin_invites.html:7
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:12
msgid "Pending invitations"
msgstr ""
#: snikket_web/templates/admin_invites.html:29
msgid "Show invite details"
msgstr ""
#: snikket_web/templates/admin_invites.html:31
msgid "Delete invitation"
msgstr ""
#: snikket_web/templates/admin_invites.html:39
msgid "Currently, there are no pending invitations."
msgstr ""
#: snikket_web/templates/admin_users.html:17
msgid "Phone number"
msgstr ""
#: snikket_web/templates/admin_users.html:18
msgid "Actions"
msgstr ""
#: snikket_web/templates/app.html:20 snikket_web/templates/login.html:36
#, python-format #, python-format
msgid "A <a href=\"%(about_url)s\">Snikket</a> server" msgid "A <a href=\"%(about_url)s\">Snikket</a> server"
msgstr "A <a href=\"%(about_url)s\">Snikket</a> server" msgstr "A <a href=\"%(about_url)s\">Snikket</a> server"
@@ -78,11 +227,19 @@ msgstr ""
msgid "Change password" msgid "Change password"
msgstr "Change password" msgstr "Change password"
#: snikket_web/templates/user_home.html:14 #: snikket_web/templates/user_home.html:15
msgid "Admin dashboard"
msgstr ""
#: snikket_web/templates/user_home.html:16
msgid "Manage users and invitations of this Snikket instance."
msgstr ""
#: snikket_web/templates/user_home.html:20
msgid "Log out" msgid "Log out"
msgstr "Log out" msgstr "Log out"
#: snikket_web/templates/user_home.html:15 #: snikket_web/templates/user_home.html:21
msgid "Exit the Snikket Web Portal, without logging out your other devices." msgid "Exit the Snikket Web Portal, without logging out your other devices."
msgstr "Exit the Snikket Web Portal, without logging out your other devices." msgstr "Exit the Snikket Web Portal, without logging out your other devices."
@@ -116,11 +273,6 @@ msgstr ""
"After changing your password, you will have to enter the new password on " "After changing your password, you will have to enter the new password on "
"all of your devices." "all of your devices."
#: snikket_web/templates/user_passwd.html:39
#: snikket_web/templates/user_profile.html:18
msgid "Back"
msgstr "Back"
#: snikket_web/templates/user_profile.html:7 #: snikket_web/templates/user_profile.html:7
msgid "Profile" msgid "Profile"
msgstr "Profile" msgstr "Profile"
@@ -129,31 +281,30 @@ msgstr "Profile"
msgid "Apply" msgid "Apply"
msgstr "Apply" msgstr "Apply"
#: snikket_web/user/__init__.py:21 #: snikket_web/user/__init__.py:23
msgid "Current password" msgid "Current password"
msgstr "Current password" msgstr "Current password"
#: snikket_web/user/__init__.py:26 #: snikket_web/user/__init__.py:28
msgid "New password" msgid "New password"
msgstr "New password" msgstr "New password"
#: snikket_web/user/__init__.py:31 #: snikket_web/user/__init__.py:33
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirm new password" msgstr "Confirm new password"
#: snikket_web/user/__init__.py:35 #: snikket_web/user/__init__.py:37
msgid "The new passwords must match." msgid "The new passwords must match."
msgstr "The new passwords must match." msgstr "The new passwords must match."
#: snikket_web/user/__init__.py:46 #: snikket_web/user/__init__.py:52
msgid "Display name"
msgstr "Display name"
#: snikket_web/user/__init__.py:50
msgid "Avatar" msgid "Avatar"
msgstr "Avatar" msgstr "Avatar"
#: snikket_web/user/__init__.py:74 #: snikket_web/user/__init__.py:76
msgid "Incorrect password" msgid "Incorrect password"
msgstr "Incorrect password" msgstr "Incorrect password"
#~ msgid "none"
#~ msgstr ""