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
hsluv~=0.0.2
flask-babel~=1.0
email-validator~=1.1

View File

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

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 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()

View File

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

View File

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

View File

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

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() }}
{% 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>

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') }}">
<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>

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: SnikketWeb 0.1.0\n"
"Report-Msgid-Bugs-To: jonas@zombofant.net\n"
"POT-Creation-Date: 2021-01-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 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
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"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-01-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 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
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 ""