You've already forked snikket-web-portal
@@ -3,3 +3,4 @@ quart~=0.11
|
||||
flask-wtf~=0.14
|
||||
hsluv~=0.0.2
|
||||
flask-babel~=1.0
|
||||
email-validator~=1.1
|
||||
|
||||
@@ -17,6 +17,7 @@ import quart.exceptions
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
import wtforms
|
||||
import flask_babel
|
||||
from flask_babel import Babel, _, lazy_gettext as _l
|
||||
|
||||
from . import colour, xmpputil
|
||||
@@ -69,7 +70,7 @@ async def login() -> typing.Union[str, quart.Response]:
|
||||
try:
|
||||
await client.login(jid, password)
|
||||
except quart.exceptions.Unauthorized:
|
||||
form.errors.setdefault("", []).append(
|
||||
form.password.errors.append(
|
||||
_("Invalid user name or password.")
|
||||
)
|
||||
else:
|
||||
@@ -166,6 +167,10 @@ def proc() -> typing.Dict[str, typing.Any]:
|
||||
|
||||
|
||||
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")
|
||||
@@ -175,8 +180,10 @@ def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
|
||||
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(admin_bp)
|
||||
|
||||
logging_config = app.config.get("LOGGING_CONFIG")
|
||||
if logging_config is not None:
|
||||
|
||||
127
snikket_web/admin/__init__.py
Normal file
127
snikket_web/admin/__init__.py
Normal 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,
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
import dataclasses
|
||||
import enum
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -5,6 +7,8 @@ import secrets
|
||||
import types
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -19,9 +23,71 @@ from . import xmpputil
|
||||
from .xmpputil import split_jid
|
||||
|
||||
|
||||
SCOPE_DEFAULT = "prosody:scope:default"
|
||||
SCOPE_ADMIN = "prosody:scope:admin"
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, app_context_attribute: str):
|
||||
self._app_context_attribute = app_context_attribute
|
||||
@@ -119,6 +185,7 @@ class ProsodyClient:
|
||||
CTX_AUTH_SESSION = "_ProsodyClient__auth_session"
|
||||
CONFIG_ENDPOINT = "PROSODY_ENDPOINT"
|
||||
SESSION_TOKEN = "prosody_access_token"
|
||||
SESSION_CACHED_SCOPE = "prosody_scope_cache"
|
||||
SESSION_ADDRESS = "prosody_jid"
|
||||
|
||||
def __init__(self, app: typing.Optional[quart.Quart] = None):
|
||||
@@ -158,19 +225,26 @@ class ProsodyClient:
|
||||
def _rest_endpoint(self) -> str:
|
||||
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,
|
||||
session: aiohttp.ClientSession,
|
||||
jid: str,
|
||||
password: str) -> None:
|
||||
password: str) -> TokenInfo:
|
||||
request = aiohttp.FormData()
|
||||
request.add_field("grant_type", "password")
|
||||
request.add_field("username", jid)
|
||||
request.add_field("password", password)
|
||||
request.add_field(
|
||||
"scope",
|
||||
" ".join([SCOPE_DEFAULT, SCOPE_ADMIN])
|
||||
)
|
||||
|
||||
self.logger.debug("sending OAuth2 request (payload omitted)")
|
||||
async with session.post(self._login_endpoint, data=request) as resp:
|
||||
auth_status = resp.status
|
||||
auth_info = (await resp.json())
|
||||
auth_info: typing.Mapping[str, str] = (await resp.json())
|
||||
|
||||
if auth_status in [400, 401]:
|
||||
self.logger.debug("oauth2 error: %r", auth_info)
|
||||
@@ -189,7 +263,10 @@ class ProsodyClient:
|
||||
auth_info["token_type"]
|
||||
)
|
||||
)
|
||||
return auth_info["access_token"]
|
||||
return TokenInfo(
|
||||
token=auth_info["access_token"],
|
||||
scopes=auth_info["scope"].split(),
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
"unexpected authentication reply: ({}) {!r}".format(
|
||||
@@ -199,10 +276,13 @@ class ProsodyClient:
|
||||
|
||||
async def login(self, jid: str, password: str) -> bool:
|
||||
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_CACHED_SCOPE] = " ".join(token_info.scopes)
|
||||
return True
|
||||
|
||||
@property
|
||||
@@ -252,6 +332,30 @@ class ProsodyClient:
|
||||
return wrapped
|
||||
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(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
@@ -309,6 +413,7 @@ class ProsodyClient:
|
||||
"nickname": nickname,
|
||||
"display_name": nickname or localpart,
|
||||
"avatar_hash": avatar_hash,
|
||||
"is_admin": self.is_admin_session,
|
||||
}
|
||||
|
||||
@autosession
|
||||
@@ -450,12 +555,115 @@ class ProsodyClient:
|
||||
# server to expire/revoke all tokens on password change.
|
||||
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:
|
||||
# this currently only kills the cookie stuff, we may want to invalidate
|
||||
# the token on the server side, toos
|
||||
# See-Also: https://issues.prosody.im/1503
|
||||
http_session.pop(self.SESSION_TOKEN, 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()
|
||||
|
||||
@@ -7,7 +7,7 @@ body {
|
||||
color: $gray-100;
|
||||
}
|
||||
|
||||
p, blockquote, ul, ol {
|
||||
p, blockquote, ul, ol, table, dl {
|
||||
line-height: 1.5;
|
||||
margin: 1.5em 0;
|
||||
font-family: $font-bulk;
|
||||
@@ -19,6 +19,10 @@ blockquote {
|
||||
margin-right: $w-l2;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
/* normalise */
|
||||
font-weight: 400;
|
||||
|
||||
@@ -59,7 +59,7 @@ $colours: (
|
||||
$box-types: (
|
||||
"primary": "blue",
|
||||
"accent": "yellow",
|
||||
"alert": "alert",
|
||||
"alert": "red",
|
||||
"warning": "yellow",
|
||||
"success": "green",
|
||||
"hint": "blue"
|
||||
|
||||
@@ -186,7 +186,7 @@ p.form-desc.weak {
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
$text-entry-inputs: "text" "password";
|
||||
$text-entry-inputs: "text" "password" "email" "tel";
|
||||
|
||||
div.f-errbox {
|
||||
background-color: $alert-800;
|
||||
@@ -390,9 +390,30 @@ div.form.layout-expanded {
|
||||
|
||||
/* 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;
|
||||
padding: $w-s3 $w-s1;
|
||||
|
||||
td & {
|
||||
margin: 0 $w-s4;
|
||||
padding: $w-s4 $w-s2;
|
||||
}
|
||||
}
|
||||
|
||||
a.button {
|
||||
@@ -400,141 +421,145 @@ a.button {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.primary, .button.primary {
|
||||
background: linear-gradient(0deg, $primary-500, $primary-600);
|
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
color: $primary-900;
|
||||
border: none;
|
||||
/* TODO: fix vertical rhyhtm ... */
|
||||
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;
|
||||
input[type="submit"], button, .button {
|
||||
&.primary {
|
||||
background: linear-gradient(0deg, $primary-500, $primary-600);
|
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
color: $primary-900;
|
||||
border: none;
|
||||
/* TODO: fix vertical rhyhtm ... */
|
||||
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 {
|
||||
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);
|
||||
color: $accent-200;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: linear-gradient(0deg, $accent-700, $accent-800);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $accent-600;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $accent-500;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(0deg, $alert-500, $alert-600);
|
||||
color: $alert-900;
|
||||
|
||||
&:hover, &:focus {
|
||||
&.danger {
|
||||
background: linear-gradient(0deg, $alert-600, $alert-700);
|
||||
}
|
||||
color: $alert-200;
|
||||
|
||||
&:active {
|
||||
background: $alert-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover, &:focus {
|
||||
background: linear-gradient(0deg, $alert-700, $alert-800);
|
||||
}
|
||||
|
||||
button.secondary, .button.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);
|
||||
color: $accent-200;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: linear-gradient(0deg, $accent-700, $accent-800);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $accent-600;
|
||||
&:active {
|
||||
background: $alert-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(0deg, $alert-600, $alert-700);
|
||||
color: $alert-200;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: linear-gradient(0deg, $alert-700, $alert-800);
|
||||
}
|
||||
|
||||
&: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;
|
||||
&.tertiary, .tertiary {
|
||||
background-color: transparent;
|
||||
color: $gray-100;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
/* TODO: fix vertical rhyhtm ... */
|
||||
border-radius: $w-s4;
|
||||
|
||||
&:hover {
|
||||
background-color: $accent-900;
|
||||
border-color: $accent-800;
|
||||
background-color: $gray-900;
|
||||
border-color: $gray-800;
|
||||
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 {
|
||||
text-decoration-color: $alert-500;
|
||||
|
||||
&:hover {
|
||||
background-color: $alert-900;
|
||||
border-color: $alert-800;
|
||||
color: black;
|
||||
}
|
||||
&.fullwidth {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button.fullwidth, .button.fullwidth {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
|
||||
/* button, .button {
|
||||
margin: 0 $w-s2;
|
||||
@@ -710,7 +735,7 @@ div.welcome-cards {
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > .card {
|
||||
flex: 1 0 $w-l6;
|
||||
flex: 1 0 $w-l7;
|
||||
margin: $w-s1;
|
||||
@extend .el-2;
|
||||
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 */
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
6
snikket_web/templates/admin_app.html
Normal file
6
snikket_web/templates/admin_app.html
Normal 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 %}
|
||||
27
snikket_web/templates/admin_delete_user.html
Normal file
27
snikket_web/templates/admin_delete_user.html
Normal 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 %}
|
||||
44
snikket_web/templates/admin_edit_invite.html
Normal file
44
snikket_web/templates/admin_edit_invite.html
Normal 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 "{{ text }}" to clipboard" aria-label="Copy "{{ text }}" 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 %}
|
||||
32
snikket_web/templates/admin_edit_user.html
Normal file
32
snikket_web/templates/admin_edit_user.html
Normal 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 %}
|
||||
19
snikket_web/templates/admin_home.html
Normal file
19
snikket_web/templates/admin_home.html
Normal 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 user’s web portal page.{% endtrans %}</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
42
snikket_web/templates/admin_invites.html
Normal file
42
snikket_web/templates/admin_invites.html
Normal 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 %}
|
||||
36
snikket_web/templates/admin_users.html
Normal file
36
snikket_web/templates/admin_users.html
Normal 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 %}
|
||||
@@ -8,9 +8,11 @@
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div id="topbar">
|
||||
<header><a href="{{ url_for('user.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header>
|
||||
<div id="topbar" class="{% block topbar_classes %}{% endblock %}">
|
||||
<header><a href="{{ url_for('.index') }}"><span>{{ config["SNIKKET_DOMAIN"] }}</span></a></header>
|
||||
{% block topbar_left %}{% endblock %}
|
||||
<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>
|
||||
</div>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
|
||||
71
snikket_web/templates/copy-snippet.html
Normal file
71
snikket_web/templates/copy-snippet.html
Normal 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>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<a class="card" href="{{ url_for('user.change_pw') }}">
|
||||
<h2>{% trans %}Change password{% endtrans %}</h2>
|
||||
</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') }}">
|
||||
<h2>{% trans %}Log out{% endtrans %}</h2>
|
||||
<p>{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}</p>
|
||||
|
||||
@@ -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-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"
|
||||
"Last-Translator: Jonas Schäfer <jonas@zombofant.net>\n"
|
||||
"Language: de\n"
|
||||
@@ -18,20 +18,173 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.9.0\n"
|
||||
|
||||
#: snikket_web/__init__.py:38
|
||||
#: snikket_web/__init__.py:40
|
||||
msgid "Address"
|
||||
msgstr "Adresse"
|
||||
|
||||
#: snikket_web/__init__.py:43
|
||||
#: snikket_web/__init__.py:45
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
||||
#: snikket_web/__init__.py:72
|
||||
#, fuzzy
|
||||
#: snikket_web/__init__.py:74
|
||||
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 user’s 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
|
||||
msgid "A <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
|
||||
msgid "Login failed"
|
||||
msgstr ""
|
||||
msgstr "Anmeldung fehlgeschlagen"
|
||||
|
||||
#: snikket_web/templates/login.html:31
|
||||
msgid "Log in"
|
||||
@@ -78,11 +231,19 @@ msgstr ""
|
||||
msgid "Change password"
|
||||
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"
|
||||
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."
|
||||
msgstr ""
|
||||
"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 "
|
||||
"all of your devices."
|
||||
msgstr ""
|
||||
"Nachdem du das Passwort geändert hast, musst du das neue Passwort auf allen"
|
||||
"Geräten manuell eintragen."
|
||||
|
||||
#: snikket_web/templates/user_passwd.html:39
|
||||
#: snikket_web/templates/user_profile.html:18
|
||||
msgid "Back"
|
||||
msgstr "Zurück"
|
||||
"Nachdem du das Passwort geändert hast, musst du das neue Passwort auf "
|
||||
"allenGeräten manuell eintragen."
|
||||
|
||||
#: snikket_web/templates/user_profile.html:7
|
||||
msgid "Profile"
|
||||
@@ -131,31 +287,30 @@ msgstr "Profil"
|
||||
msgid "Apply"
|
||||
msgstr "Übernehmen"
|
||||
|
||||
#: snikket_web/user/__init__.py:21
|
||||
#: snikket_web/user/__init__.py:23
|
||||
msgid "Current password"
|
||||
msgstr "Aktuelles Passwort"
|
||||
|
||||
#: snikket_web/user/__init__.py:26
|
||||
#: snikket_web/user/__init__.py:28
|
||||
msgid "New password"
|
||||
msgstr "Neues Passwort"
|
||||
|
||||
#: snikket_web/user/__init__.py:31
|
||||
#: snikket_web/user/__init__.py:33
|
||||
msgid "Confirm new password"
|
||||
msgstr "Neues Passwort (Bestätigung)"
|
||||
|
||||
#: snikket_web/user/__init__.py:35
|
||||
#: snikket_web/user/__init__.py:37
|
||||
msgid "The new passwords must match."
|
||||
msgstr "Die neuen Passwörter müssen übereinstimmen."
|
||||
|
||||
#: snikket_web/user/__init__.py:46
|
||||
msgid "Display name"
|
||||
msgstr "Anzeigename"
|
||||
|
||||
#: snikket_web/user/__init__.py:50
|
||||
#: snikket_web/user/__init__.py:52
|
||||
msgid "Avatar"
|
||||
msgstr "Bild"
|
||||
|
||||
#: snikket_web/user/__init__.py:74
|
||||
#: snikket_web/user/__init__.py:76
|
||||
msgid "Incorrect password"
|
||||
msgstr "Ungültiges Passwort"
|
||||
|
||||
#~ msgid "none"
|
||||
#~ msgstr "keiner"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\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"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: en\n"
|
||||
@@ -18,20 +18,169 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.9.0\n"
|
||||
|
||||
#: snikket_web/__init__.py:38
|
||||
#: snikket_web/__init__.py:40
|
||||
msgid "Address"
|
||||
msgstr "Address"
|
||||
|
||||
#: snikket_web/__init__.py:43
|
||||
#: snikket_web/__init__.py:45
|
||||
msgid "Password"
|
||||
msgstr "Password"
|
||||
|
||||
#: snikket_web/__init__.py:72
|
||||
#: snikket_web/__init__.py:74
|
||||
#, fuzzy
|
||||
msgid "Invalid user name or 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 user’s 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
|
||||
msgid "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"
|
||||
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"
|
||||
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."
|
||||
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 "
|
||||
"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
|
||||
msgid "Profile"
|
||||
msgstr "Profile"
|
||||
@@ -129,31 +281,30 @@ msgstr "Profile"
|
||||
msgid "Apply"
|
||||
msgstr "Apply"
|
||||
|
||||
#: snikket_web/user/__init__.py:21
|
||||
#: snikket_web/user/__init__.py:23
|
||||
msgid "Current password"
|
||||
msgstr "Current password"
|
||||
|
||||
#: snikket_web/user/__init__.py:26
|
||||
#: snikket_web/user/__init__.py:28
|
||||
msgid "New password"
|
||||
msgstr "New password"
|
||||
|
||||
#: snikket_web/user/__init__.py:31
|
||||
#: snikket_web/user/__init__.py:33
|
||||
msgid "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."
|
||||
msgstr "The new passwords must match."
|
||||
|
||||
#: snikket_web/user/__init__.py:46
|
||||
msgid "Display name"
|
||||
msgstr "Display name"
|
||||
|
||||
#: snikket_web/user/__init__.py:50
|
||||
#: snikket_web/user/__init__.py:52
|
||||
msgid "Avatar"
|
||||
msgstr "Avatar"
|
||||
|
||||
#: snikket_web/user/__init__.py:74
|
||||
#: snikket_web/user/__init__.py:76
|
||||
msgid "Incorrect password"
|
||||
msgstr "Incorrect password"
|
||||
|
||||
#~ msgid "none"
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user