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

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