feat: redesign ui using bootstrap
All checks were successful
/ test (push) Successful in 1m22s
/ codestyle (push) Successful in 1m6s
/ build_wheel (push) Successful in 2m0s
/ build_debian (push) Successful in 2m32s

feat: split user settings and admin settings
fix: list user tokens in admin user settings
feat!: remove osk, osk should be provided by kiosk browser
This commit is contained in:
s3lph 2024-12-07 15:50:37 +01:00
parent b3b47b6b60
commit 67e2a813d5
Signed by: s3lph
GPG key ID: 0AA29A52FB33CFB5
32 changed files with 959 additions and 1016 deletions

View file

@ -1,5 +1,21 @@
# Matemat Changelog
<!-- BEGIN RELEASE v0.4.0 -->
## Version 0.4.0
Bootstrap UI Release
### Changes
<!-- BEGIN CHANGES 0.4.0 -->
- feat: redesign ui using bootstrap
- feat: split user settings and admin settings
- fix: list user tokens in admin user settings
- feat!: remove osk, osk should be provided by kiosk browser
<!-- END CHANGES 0.4.0 -->
<!-- END RELEASE v0.4.0 -->
<!-- BEGIN RELEASE v0.3.18 -->
## Version 0.3.18

View file

@ -1,2 +1,2 @@
__version__ = '0.3.18'
__version__ = '0.4.0'

View file

@ -18,3 +18,4 @@ from .moduser import moduser
from .modproduct import modproduct
from .userbootstrap import userbootstrap
from .statistics import statistics
from .settings import settings

View file

@ -20,8 +20,7 @@ from matemat.webserver.template import Notification
@post('/admin')
def admin():
"""
The admin panel, shows a user's own settings. Additionally, for administrators, settings to modify other users and
products are shown.
The admin panel, shows settings to modify other users and products.
"""
config = get_app_config()
session_id: str = session.start()
@ -38,18 +37,15 @@ def admin():
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
user = db.get_user(uid)
# If the POST request contains a "change" parameter, delegate the change handling to the function below
if 'change' in request.params:
handle_change(request.params, request.files, user, db)
# If the POST request contains an "adminchange" parameter, delegate the change handling to the function below
elif 'adminchange' in request.params and user.is_admin:
handle_admin_change(request.params, request.files, db)
if 'adminchange' in request.params and user.is_admin:
handle_change(request.params, request.files, db)
# Fetch all existing users and products from the database
users = db.list_users()
tokens = db.list_tokens(uid)
products = db.list_products()
# Render the "Admin/Settings" page
# Render the "Admin" page
now = str(int(datetime.now(UTC).timestamp()))
return template.render('admin.html',
authuser=user, authlevel=authlevel, tokens=tokens, users=users, products=products,
@ -57,134 +53,7 @@ def admin():
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematDatabase) -> None:
"""
Write the changes requested by a user for its own account to the database.
:param args: The FormsDict object passed to the pagelet.
:param user: The user to edit.
:param db: The database facade where changes are written to.
"""
config = get_app_config()
try:
# Read the type of change requested by the user, then switch over it
change = str(args.change)
# The user requested a modification of its general account information (username, email)
if change == 'account':
# Username and email must be set in the request arguments
if 'username' not in args or 'email' not in args:
return
username = str(args.username)
email = str(args.email)
logout_after_purchase = 'logout_after_purchase' in args
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
try:
receipt_pref = ReceiptPreference(int(str(args.receipt_pref)))
except ValueError:
return
# Attempt to update username, e-mail and receipt preference
try:
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref,
logout_after_purchase=logout_after_purchase)
except DatabaseConsistencyError:
return
# The user requested a password change
elif change == 'password':
# The old password and 2x the new password must be present
if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args:
return
# Read the passwords from the request arguments
oldpass = str(args.oldpass)
newpass = str(args.newpass)
newpass2 = str(args.newpass2)
# The two instances of the new password must match
if newpass != newpass2:
raise ValueError('New passwords don\'t match')
# Write the new password to the database
try:
db.change_password(user, oldpass, newpass)
except AuthenticationError:
raise ValueError('Old password doesn\'t match')
# The user requested a touchkey change
elif change == 'touchkey':
# The touchkey must be present
if 'touchkey' not in args:
return
# Read the touchkey from the request arguments
touchkey = str(args.touchkey)
# An empty touchkey field should set the touchkey to NULL (disable touchkey login)
if len(touchkey) == 0:
touchkey = None
# Write the new touchkey to the database
db.change_touchkey(user, '', touchkey, verify_password=False)
# The user added a new token
elif change == 'addtoken':
if 'token' not in args:
return
token = str(args.token)
if len(token) < 6:
return
name = None if 'name' not in args or len(args.name) == 0 else str(args.name)
try:
tokobj = db.add_token(user, token, name)
Notification.success(f'Token {tokobj.name} created successfully')
except DatabaseConsistencyError:
Notification.error('Token already exists', decay=True)
elif change == 'deltoken':
try:
tokid = int(str(request.params.token))
token = db.get_token(tokid)
except Exception as e:
Notification.error('Token not found', decay=True)
return
if token.user_id != user.id:
Notification.error('Token not found', decay=True)
return
try:
db.delete_token(token)
except DatabaseConsistencyError:
Notification.error(f'Failed to delete token {token.name}', decay=True)
Notification.success(f'Token {token.name} removed', decay=True)
# The user requested an avatar change
elif change == 'avatar':
# The new avatar field must be present
if 'avatar' not in files:
return
# Read the raw image data from the request
avatar = files.avatar.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0:
return
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
if not filemagic.mime_type.startswith('image/'):
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.makedirs(abspath, exist_ok=True)
try:
# Parse the image data
image: Image = Image.open(BytesIO(avatar))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{user.id}.png'), 'PNG')
except OSError:
return
except UnicodeDecodeError:
raise ValueError('an argument not a string')
def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
def handle_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
"""
Write the changes requested by an admin for users of products.

View file

@ -21,7 +21,7 @@ def login_page():
redirect('/')
# If requested via HTTP GET, render the login page showing the login UI
if request.method == 'GET':
return template.render('login.html',
return template.render('login.html', signup=(config.get('SignupEnabled', '0') == '1'),
setupname=config['InstanceName'])
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif request.method == 'POST':

View file

@ -27,7 +27,8 @@ def main_page():
try:
buyproduct = db.get_product_by_ean(request.params.ean)
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
f'Login will purchase <strong>{buyproduct.name}</strong>. ' +
'Click <a class="alert-link" href="/">here</a> to abort.')
except ValueError:
if not session.has(session_id, 'authenticated_user'):
try:

View file

@ -56,10 +56,11 @@ def moduser():
redirect('/admin')
# Render the "Modify User" page
tokens = db.list_tokens(moduser_id)
now = str(int(datetime.now(UTC).timestamp()))
return template.render('moduser.html',
authuser=authuser, user=user, authlevel=authlevel, now=now,
receipt_preference_class=ReceiptPreference,
receipt_preference_class=ReceiptPreference, tokens=tokens,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])

View file

@ -0,0 +1,178 @@
import os
from datetime import datetime, UTC
from io import BytesIO
from shutil import copyfile
import magic
from PIL import Image
from bottle import get, post, abort, redirect, request, FormsDict
from matemat.db import MatematDatabase
from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.webserver import session, template
from matemat.webserver.config import get_app_config, get_stock_provider
from matemat.webserver.template import Notification
@get('/settings')
@post('/settings')
def settings():
"""
The settings panel, shows a user's own settings.
"""
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the login page
if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'):
redirect('/login')
authlevel: int = session.get(session_id, 'authentication_level')
uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey or token (1)
if authlevel < 2:
abort(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
user = db.get_user(uid)
# If the POST request contains a "change" parameter, delegate the change handling to the function below
if 'change' in request.params:
handle_change(request.params, request.files, user, db)
user = db.get_user(uid)
tokens = db.list_tokens(uid)
# Render the "Settings" page
now = str(int(datetime.now(UTC).timestamp()))
return template.render('settings.html',
authuser=user, authlevel=authlevel, tokens=tokens,
receipt_preference_class=ReceiptPreference, now=now,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematDatabase) -> None:
"""
Write the changes requested by a user for its own account to the database.
:param args: The FormsDict object passed to the pagelet.
:param user: The user to edit.
:param db: The database facade where changes are written to.
"""
config = get_app_config()
try:
# Read the type of change requested by the user, then switch over it
change = str(args.change)
# The user requested a modification of its general account information (username, email)
if change == 'account':
# Username and email must be set in the request arguments
if 'username' not in args or 'email' not in args:
return
username = str(args.username)
email = str(args.email)
logout_after_purchase = 'logout_after_purchase' in args
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
try:
receipt_pref = ReceiptPreference(int(str(args.receipt_pref)))
except ValueError:
return
# Attempt to update username, e-mail and receipt preference
try:
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref,
logout_after_purchase=logout_after_purchase)
except DatabaseConsistencyError:
return
# The user requested a password change
elif change == 'password':
# The old password and 2x the new password must be present
if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args:
return
# Read the passwords from the request arguments
oldpass = str(args.oldpass)
newpass = str(args.newpass)
newpass2 = str(args.newpass2)
# The two instances of the new password must match
if newpass != newpass2:
raise ValueError('New passwords don\'t match')
# Write the new password to the database
try:
db.change_password(user, oldpass, newpass)
except AuthenticationError:
raise ValueError('Old password doesn\'t match')
# The user requested a touchkey change
elif change == 'touchkey':
# The touchkey must be present
if 'touchkey' not in args:
return
# Read the touchkey from the request arguments
touchkey = str(args.touchkey)
# An empty touchkey field should set the touchkey to NULL (disable touchkey login)
if len(touchkey) == 0:
touchkey = None
# Write the new touchkey to the database
db.change_touchkey(user, '', touchkey, verify_password=False)
# The user added a new token
elif change == 'addtoken':
if 'token' not in args:
return
token = str(args.token)
if len(token) < 6:
return
name = None if 'name' not in args or len(args.name) == 0 else str(args.name)
try:
tokobj = db.add_token(user, token, name)
Notification.success(f'Token {tokobj.name} created successfully')
except DatabaseConsistencyError:
Notification.error('Token already exists', decay=True)
elif change == 'deltoken':
try:
tokid = int(str(request.params.token))
token = db.get_token(tokid)
except Exception as e:
Notification.error('Token not found', decay=True)
return
if token.user_id != user.id:
Notification.error('Token not found', decay=True)
return
try:
db.delete_token(token)
except DatabaseConsistencyError:
Notification.error(f'Failed to delete token {token.name}', decay=True)
Notification.success(f'Token {token.name} removed', decay=True)
# The user requested an avatar change
elif change == 'avatar':
# The new avatar field must be present
if 'avatar' not in files:
return
# Read the raw image data from the request
avatar = files.avatar.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0:
return
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
if not filemagic.mime_type.startswith('image/'):
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.makedirs(abspath, exist_ok=True)
try:
# Parse the image data
image: Image = Image.open(BytesIO(avatar))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{user.id}.png'), 'PNG')
except OSError:
return
except UnicodeDecodeError:
raise ValueError('an argument not a string')

View file

@ -94,7 +94,9 @@ def signup():
acl = netaddr.IPSet([addr.strip() for addr in config.get('SignupKioskMode', '').split(',')])
if request.remote_addr in acl:
return template.render('signup_kiosk.html',
signup=(config.get('SignupEnabled', '0') == '1'),
zip=zip,
setupname=config['InstanceName'])
return template.render('signup.html',
signup=(config.get('SignupEnabled', '0') == '1'),
setupname=config['InstanceName'])

View file

@ -29,10 +29,11 @@ def touchkey_page():
try:
buyproduct = db.get_product(int(buypid))
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
f'Login will purchase <strong>{buyproduct.name}</strong>. ' +
'Click <a class="alert-link" href="/">here</a> to abort.')
except ValueError:
Notification.error(f'No product with id {buypid}', decay=True)
return template.render('touchkey.html',
return template.render('touchkey.html', signup=(config.get('SignupEnabled', '0') == '1'),
username=str(request.params.username), uid=int(str(request.params.uid)),
setupname=config['InstanceName'], buypid=buypid)
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials

View file

@ -22,9 +22,9 @@ class Notification:
@classmethod
def success(cls, msg: str, decay: bool = False):
session_id: str = session.start()
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['success'], decay=decay))
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['alert-success'], decay=decay))
@classmethod
def error(cls, msg: str, decay: bool = False):
session_id: str = session.start()
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['error'], decay=decay))
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['alert-danger'], decay=decay))

7
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,70 +1,5 @@
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
nav div {
display: inline-block;
}
.thumblist-item {
display: inline-block;
margin: 5px 5px 10px 10px;
padding: 15px;
background: #f0f0f0;
text-decoration: none;
}
.thumblist-item a {
text-decoration: none;
}
.thumblist-item .imgcontainer {
width: 100px;
height: 100px;
position: relative;
}
.thumblist-item .imgcontainer img {
max-width: 100px;
max-height: 100px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.thumblist-title {
display: block;
font-weight: bolder;
}
.thumblist-stock {
position: absolute;
z-index: 10;
bottom: 0;
right: 0;
background: #f0f0f0;
padding: 10px;
}
.notification {
display: block;
width: calc(100% - 36px);
margin: 10px;
padding: 10px;
}
.notification.success {
background-color: #c0ffc0;
}
.notification.error {
background-color: #ffc0c0;
}
.notification.decay {
.alert.decay {
animation: notificationdecay 0s 7s forwards;
}
@keyframes notificationdecay {
@ -83,10 +18,6 @@ nav div {
}
}
#depositlist {
display: inline;
}
#deposit-wrapper {
display: none;
position: absolute;
@ -149,6 +80,11 @@ nav div {
font-family: monospace;
}
#touchkey-svg {
width: 400px;
height: auto;
}
.numpad {
background: #f0f0f0;
text-decoration: none;
@ -274,46 +210,26 @@ nav div {
background: #60f060;
}
div.osk-kbd {
display: none;
font-family: sans-serif;
position: fixed;
left: 0;
bottom: 0;
right: 0;
flex-direction: column;
z-index: 100;
background: white;
font-size: 5vh;
.itemlist a {
text-decoration: none;
}
.itemlist a:visited {
text-decoration: none;
}
div.osk-kbd.visible {
display: flex;
.itemlist img {
max-width: 100%;
max-height: 128px;
height: auto;
object-fit: cover;
}
div.osk-kbd-row {
width: 100%;
height: 10vh;
flex-grow: 1;
display: flex;
flex-direction: row;
.card-img-overlay {
padding: 0;
left: auto;
opacity: .7;
}
div.osk-button {
flex: 1 0 1px;
background: #f0f0f0;
padding: 5px;
margin: 2px;
text-align: center;
line-height: calc(10vh - 10px);
}
div.osk-button:active, div.osk-button.osk-locked {
background: #606060;
}
div.osk-button.osk-button-space {
flex: 5 0 1px;
color: #606060;
.card-img-overlay span {
padding: .5em;
}

7
static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -15,16 +15,17 @@ let product_id = null;
let target_user = null;
let target_user_li = null;
let deposit = '0';
let button = document.createElement('div');
let button_transfer = document.createElement('div');
let button = document.createElement('a');
let button_transfer = document.createElement('a');
let input = document.getElementById('deposit-wrapper');
let amount = document.getElementById('deposit-amount');
let title = document.getElementById('deposit-title');
let userlist = document.getElementById('transfer-userlist');
let userlist_list = document.getElementById('transfer-userlist-list');
let ok_button = document.getElementById('numpad-ok');
button.classList.add('thumblist-item');
button.classList.add('fakelink');
button.classList.add('btn');
button.classList.add('btn-primary');
button.classList.add('me-2');
button.innerText = 'Deposit';
button.onclick = (ev) => {
mode = Mode.Deposit;
@ -37,8 +38,9 @@ button.onclick = (ev) => {
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
};
button_transfer.classList.add('thumblist-item');
button_transfer.classList.add('fakelink');
button_transfer.classList.add('btn');
button_transfer.classList.add('btn-primary');
button_transfer.classList.add('me-2');
button_transfer.innerText = 'Transfer';
button_transfer.onclick = (ev) => {
mode = Mode.Transfer;

View file

@ -1,38 +1,164 @@
{% extends "base.html" %}
{% block header %}
{# If the logged in user is an administrator, call the title "Administration", otherwise "Settings" #}
{% if authuser.is_admin %}
<h1>Administration</h1>
{% else %}
<h1>Settings</h1>
{% endif %}
{{ super() }}
{% endblock %}
{% block main %}
{# Always show the settings a user can edit for itself #}
{% include "admin_all.html" %}
<h1>Administration</h1>
{# Only show the "restricted" section if the user is an admin #}
{% if authuser.is_admin %}
{% include "admin_restricted.html" %}
{% endif %}
<ul class="nav nav-tabs" id="adminTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-users-tab" data-bs-toggle="tab" data-bs-target="#admin-users-tab-pane" type="button" role="tab" aria-controls="admin-users-tab-pane" aria-selected="true">Users</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-products-tab" data-bs-toggle="tab" data-bs-target="#admin-products-tab-pane" type="button" role="tab" aria-controls="admin-products-tab-pane" aria-selected="false">Products</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-default-images-tab" data-bs-toggle="tab" data-bs-target="#admin-default-images-tab-pane" type="button" role="tab" aria-controls="admin-default-images-tab-pane" aria-selected="false">Default Images</button>
</li>
</ul>
<div class="tab-content" id="adminTabContent">
<section class="tab-pane fade pt-3 show active" id="admin-users-tab-pane" role="tabpanel">
<h2>Users</h2>
<form id="admin-newuser-form" method="post" action="/admin?adminchange=newuser" accept-charset="UTF-8">
<table class="table table-striped">
<tr>
<th>Username</th>
<th>E-Mail (optional)</th>
<th>Password</th>
<th>Member</th>
<th>Admin</th>
<th>Logout after purchase</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="admin-newuser-username" type="text" name="username" placeholder="New username"></td>
<td><input class="form-control" id="admin-newuser-email" type="text" name="email" placeholder="New e-mail"></td>
<td><input class="form-control" id="admin-newuser-password" type="password" name="password" placeholder="New password"></td>
<td><input class="form-check-input" id="admin-newuser-ismember" type="checkbox" name="ismember"></td>
<td><input class="form-check-input" id="admin-newuser-isadmin" type="checkbox" name="isadmin"></td>
<td><input class="form-check-input" id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase"></td>
<td><input class="btn btn-success" type="submit" value="Create User"></td>
</tr>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ '✓' if user.email else '✗' }}</td>
<td>••••••••</td>
<td>{{ '✓' if user.is_member else '✗' }}</td>
<td>{{ '✓' if user.is_admin else '✗' }}</td>
<td>{{ '✓' if user.logout_after_purchase else '✗' }}</td>
<td>
<a class="btn btn-primary" href="/moduser?userid={{ user.id }}">Edit</a>
<a class="btn btn-danger" href="/moduser?userid={{ user.id }}&change=del">Delete</a>
</td>
</tr>
{% endfor %}
</table>
</form>
</section>
<section class="tab-pane fade pt-3" id="admin-products-tab-pane" role="tabpanel">
<h2>Products</h2>
<form id="admin-newproduct-form" method="post" action="/admin?adminchange=newproduct" enctype="multipart/form-data" accept-charset="UTF-8">
<table class="table table-striped">
<tr>
<th>Name</th>
<th>EAN code</th>
<th>Member price</th>
<th>Non-member price</th>
<th>Custom price</th>
<th>Stockable</th>
<th>Image</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td>
<td><input class="form-control" id="admin-newproduct-ean" type="text" name="ean" placeholder="Scan to insert EAN"></td>
<td>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0">
</div>
</td>
<td>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0">
</div>
</td>
<td><input class="form-check-input" id="admin-custom-price" type="checkbox" name="custom_price"></td>
<td><input class="form-check-input" id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked"></td>
<td><input class="form-control" id="admin-newproduct-image" name="image" type="file" accept="image/*"></td>
<td><input class="btn btn-success" type="submit" value="Create Product"></td>
</tr>
{% for product in products %}
<tr>
<td>{{ product.name }}</td>
<td>{{ product.ean or '' }}</td>
<td>{{ product.price_member | chf }}</td>
<td>{{ product.price_non_member | chf }}</td>
<td>{{ '✓' if product.custom_price else '✗' }}</td>
<td>{{ '✓' if product.stockable else '✗' }}</td>
<td><img style="height: 2em;" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ product.name }}" draggable="false"></td>
<td>
<a class="btn btn-primary" href="/modproduct?productid={{ product.id }}">Edit</a>
<a class="btn btn-danger" href="/modproduct?productid={{ product.id }}&change=del">Delete</a>
</td>
</tr>
{% endfor %}
</table>
</form>
</section>
<section class="tab-pane fade pt-3" id="admin-default-images-tab-pane" role="tabpanel">
<h2>Default Images</h2>
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<div class="row">
<div class="col-sm-2 g-4">
<div class="card h-100">
<img class="card-img-top" src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
<div class="card-body">
<label class="card-title" for="admin-default-images-user">Default user avatar</label>
<div class="card-text">
<input class="form-control" id="admin-default-images-user" type="file" name="users" accept="image/*" />
</div>
</div>
</div>
</div>
<div class="col-sm-2 g-4">
<div class="card h-100">
<img class="card-img-top" src="/static/upload/thumbnails/product/default.png" alt="Default product image" />
<div class="card-body">
<label class="card-title" for="admin-default-images-product">Default product image</label>
<div class="card-text">
<input class="form-control" id="admin-default-images-product" type="file" name="products" accept="image/*" />
</div>
</div>
</div>
</div>
</div>
<p>
<input class="btn btn-primary" type="submit" value="Save changes">
</p>
</form>
</section>
{{ super() }}
{% endblock %}
{% block eanwebsocket %}
let tokeninput = document.getElementById("admin-newtoken-token");
tokeninput.value = e.data;
tokeninput.select();
{% if authuser.is_admin %}
let eaninput = document.getElementById("admin-newproduct-ean");
eaninput.value = e.data;
eaninput.select();
{% else %}
tokeninput.scrollIntoView();
{% endif %}
eaninput.scrollIntoView();
{% endblock %}

View file

@ -1,113 +0,0 @@
<section id="admin-myaccount">
<h2>My Account</h2>
<form id="admin-myaccount-form" method="post" action="/admin?change=account" accept-charset="UTF-8">
<label for="admin-myaccount-username">Username: </label>
<input id="admin-myaccount-username" type="text" name="username" value="{{ authuser.name }}" /><br/>
<label for="admin-myaccount-email">E-Mail: </label>
<input id="admin-myaccount-email" type="text" name="email" value="{% if authuser.email is not none %}{{ authuser.email }}{% endif %}" /><br/>
<label for="admin-myaccount-receipt-pref">Receipts: </label>
<select id="admin-myaccount-receipt-pref" name="receipt_pref">
{% for pref in receipt_preference_class %}
<option value="{{ pref.value }}" {% if authuser.receipt_pref == pref %} selected="selected" {% endif %}>{{ pref.human_readable }}</option>
{% endfor %}
</select>
{% if config_smtp_enabled != '1' %}Sending receipts is disabled by your administrator.{% endif %}
<br/>
<label for="admin-myaccount-ismember">Member: </label>
<input id="admin-myaccount-ismember" type="checkbox" disabled="disabled" {% if authuser.is_member %} checked="checked" {% endif %}/><br/>
<label for="admin-myaccount-isadmin">Admin: </label>
<input id="admin-myaccount-isadmin" type="checkbox" disabled="disabled" {% if authuser.is_admin %} checked="checked" {% endif %}/><br/>
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if authuser.logout_after_purchase %} checked="checked" {% endif %}/><br/>
<input type="submit" value="Save changes" />
</form>
</section>
<section id="admin-avatar">
<h2>Avatar</h2>
<form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
<img src="/static/upload/thumbnails/users/{{ authuser.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ authuser.name }}" /><br/>
<label for="admin-avatar-avatar">Upload new file: </label>
<input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input type="submit" value="Save changes" />
</form>
</section>
<section id="admin-password">
<h2>Password</h2>
<form id="admin-password-form" method="post" action="/admin?change=password" accept-charset="UTF-8">
<label for="admin-password-oldpass">Current password: </label>
<input id="admin-password-oldpass" type="password" name="oldpass" /><br/>
<label for="admin-password-newpass">New password: </label>
<input id="admin-password-newpass" type="password" name="newpass" /><br/>
<label for="admin-password-newpass2">Repeat password: </label>
<input id="admin-password-newpass2" type="password" name="newpass2" /><br/>
<input type="submit" value="Save changes" />
</form>
</section>
<section id="admin-touchkey">
<h2>Touchkey</h2>
<form id="admin-touchkey-form" method="post" action="/admin?change=touchkey" accept-charset="UTF-8">
Draw a new touchkey (leave empty to disable):
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="admin-touchkey-touchkey" type="hidden" name="touchkey" value="" />
<input type="submit" value="Save changes" />
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'admin-touchkey-touchkey');
</script>
</section>
<section id="admin-tokens">
<h2>Tokens</h2>
<strong>Warning:</strong>
Login tokens are a convenience feature that if used may weaken security.
Make sure you only use tokens not easily accessible to other people.
<form id="admin-newtoken-form" method="post" action="/admin?change=addtoken" accept-charset="UTF-8">
<table border="1">
<tr>
<th>Token</th>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
<tr>
<td><input id="admin-newtoken-token" type="password" name="token" value=""></td>
<td><input id="admin-newtoken-name" type="text" name="name" value=""></td>
<td></td>
<td><input type="submit" value="Create Token"></td>
</tr>
{% for token in tokens %}
<tr>
<td>••••••••</td>
<td>{{ token.name }}</td>
<td>{{ token.date }}</td>
<td><a style="text-decoration: none; color: #ff0000;" href="/admin?change=deltoken&token={{ token.id }}">🗑</a></td>
</tr>
{% endfor %}
</table>
</form>
</section>

View file

@ -1,118 +0,0 @@
<section id="admin-restricted-newuser">
<h2>Users</h2>
<form id="admin-newuser-form" method="post" action="/admin?adminchange=newuser" accept-charset="UTF-8">
<table border="1">
<tr>
<th>Username</th>
<th>E-Mail (optional)</th>
<th>Password</th>
<th>Member</th>
<th>Admin</th>
<th>Logout after purchase</th>
<th>Actions</th>
</tr>
<tr>
<td><input id="admin-newuser-username" type="text" name="username" placeholder="New username"></td>
<td><input id="admin-newuser-email" type="text" name="email" placeholder="New e-mail"></td>
<td><input id="admin-newuser-password" type="password" name="password" placeholder="New password"></td>
<td><input id="admin-newuser-ismember" type="checkbox" name="ismember"></td>
<td><input id="admin-newuser-isadmin" type="checkbox" name="isadmin"></td>
<td><input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase"></td>
<td><input type="submit" value="Create User"></td>
</tr>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ '✓' if user.email else '✗' }}</td>
<td>••••••••</td>
<td>{{ '✓' if user.is_member else '✗' }}</td>
<td>{{ '✓' if user.is_admin else '✗' }}</td>
<td>{{ '✓' if user.logout_after_purchase else '✗' }}</td>
<td>
<a style="text-decoration: none; color: #0000ff;" href="/moduser?userid={{ user.id }}">🖊</a>
<a style="text-decoration: none; color: #ff0000;" href="/moduser?userid={{ user.id }}&change=del">🗑</a>
</td>
</tr>
{% endfor %}
</table>
</form>
</section>
<section id="admin-restricted-newproduct">
<h2>Products</h2>
<form id="admin-newproduct-form" method="post" action="/admin?adminchange=newproduct" enctype="multipart/form-data" accept-charset="UTF-8">
<table border="1">
<tr>
<th>Name</th>
<th>EAN code</th>
<th>Member price</th>
<th>Non-member price</th>
<th>Custom price</th>
<th>Stockable</th>
<th>Image</th>
<th>Actions</th>
</tr>
<tr>
<td><input id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td>
<td><input id="admin-newproduct-ean" type="text" name="ean" placeholder="Scan to insert EAN"></td>
<td>CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0"></td>
<td>CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0"></td>
<td><input id="admin-custom-price" type="checkbox" name="custom_price"></td>
<td><input id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked"></td>
<td><input id="admin-newproduct-image" name="image" type="file" accept="image/*"></td>
<td><input type="submit" value="Create Product"></td>
</tr>
{% for product in products %}
<tr>
<td>{{ product.name }}</td>
<td>{{ product.ean or '' }}</td>
<td>{{ product.price_member | chf }}</td>
<td>{{ product.price_non_member | chf }}</td>
<td>{{ '✓' if product.custom_price else '✗' }}</td>
<td>{{ '✓' if product.stockable else '✗' }}</td>
<td><img style="height: 2em;" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ product.name }}" draggable="false"></td>
<td>
<a style="text-decoration: none; color: #0000ff;" href="/modproduct?productid={{ product.id }}">🖊</a>
<a style="text-decoration: none; color: #ff0000;" href="/modproduct?productid={{ product.id }}&change=del">🗑</a>
</td>
</tr>
{% endfor %}
</table>
</form>
</section>
<section id="admin-restricted-default-images">
<h2>Set default images</h2>
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<table>
<tr>
<th>Default user avatar</th>
<th>Default product image</th>
</tr>
<tr>
<td>
<label for="admin-default-images-user">
<img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
</label>
</td>
<td>
<label for="admin-default-images-product">
<img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
</label>
</td>
</tr>
<tr>
<td>
<input id="admin-default-images-user" type="file" name="users" accept="image/*" />
</td>
<td>
<input id="admin-default-images-product" type="file" name="products" accept="image/*" />
</td>
</tr>
</table>
<input type="submit" value="Save changes">
</form>
</section>

View file

@ -2,41 +2,56 @@
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block head %}
<meta http-equiv="encoding" charset="utf-8" />
{# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #}
<title>{{ setupname|safe }}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css" />
<link rel="stylesheet" href="/static/css/matemat.css"/>
<link rel="stylesheet" href="/static/css/theme.css"/>
{% endblock %}
<script src="/static/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<header>
<header class="navbar navbar-expand-lg navbar-dark bg-dark">
{% block header %}
<nav class="navbarbutton">
<nav class="container-fluid">
<a class="navbar-brand" href="/">{{ setupname|safe }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-collapse" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{# Show a link to the settings, if a user logged in via password (authlevel 2). #}
{% if authlevel|default(0) > 1 %}
{% if authuser is defined %}
<a href="/">Home</a>
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
{% if authuser is defined and authlevel|default(0) > 1 %}
<li class="nav-item"><a href="/settings" class="nav-link">Settings</a></li>
{% if authuser.is_admin %}
<a href="/admin">Administration</a>
<a href="/statistics">Sales Statistics</a>
<li class="nav-item"><a href="/admin" class="nav-link">Administration</a></li>
<li class="nav-item"><a href="/statistics" class="nav-link">Sales Statistics</a></li>
{% endif %}
{% endif %}
{# Login/Logout buttons #}
{% if authuser is defined %}
<li class="nav-item justify-content-end"><a href="/logout" class="nav-link">Logout</a></li>
{% else %}
<a href="/admin">Settings</a>
{% endif %}
<li class="nav-item justify-content-end"><a href="/login" class="nav-link">Login</a></li>
{% if signup|default(false) %}
<li class="nav-item justify-content-end"><a href="/signup" class="nav-link">Signup</a></li>
{% endif %}
{% endif %}
</ul>
</div>
</nav>
{% endblock %}
</header>
<main>
<main class="container-fluid pb-5 pt-3">
{% block notifications %}
{% for n in notifications | default([]) %}
<div class="notification {{ n.classes | join(' ') }}">{{ n.msg|safe }}</div>
<div class="alert {{ n.classes | join(' ') }}" role="alert">
{{ n.msg|safe }}
</div>
{% endfor %}
{% endblock %}
{% block main %}
@ -44,15 +59,11 @@
{% endblock %}
</main>
<footer>
<footer class="fixed-bottom p-3 bg-light">
{% block footer %}
{# Show some information in the footer, e.g. the instance name, the version, and copyright info. #}
<ul>
<li> {{ setupname|safe }}
<li> Matemat {{ __version__ }}
<li> MIT License
<li> git.kabelsalat.ch/s3lph/matemat
</ul>
<div class="container text-muted">
{{ setupname|safe }} | Matemat {{ __version__ }}
</div>
{% endblock %}
</footer>

View file

@ -1,27 +1,21 @@
{% extends "base.html" %}
{% block header %}
<h1>Welcome</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Welcome</h1>
{# Show a username/password login form #}
<form method="post" action="/login" id="loginform" accept-charset="UTF-8">
<label for="login-username">Username: </label>
<input id="login-username" type="text" name="username"/><br/>
<label class="form-label" for="login-username">Username: </label>
<input class="form-control" id="login-username" type="text" name="username"/><br/>
<label for="login-password">Password: </label>
<input id="login-password" type="password" name="password"/><br/>
<label class="form-label" for="login-password">Password: </label>
<input class="form-control" id="login-password" type="password" name="password"/><br/>
<input type="submit" value="Login">
<input class="btn btn-primary" type="submit" value="Login">
<a class="btn btn-secondary" href="/">Cancel</a>
</form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
{{ super() }}
{% endblock %}

View file

@ -1,50 +1,58 @@
{% extends "base.html" %}
{% block header %}
<h1>Administration</h1>
{{ super() }}
{% endblock %}
{% block main %}
<section id="modproduct">
<h2>Modify {{ product.name }}</h2>
<h1>Modify {{ product.name }}</h1>
<form id="modproduct-form" method="post" action="/modproduct?change=update" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="modproduct-name">Name: </label>
<input id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
<label class="form-label" for="modproduct-name">Name: </label>
<input class="form-control" id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
<label for="modproduct-ean">EAN code: </label>
<input id="modproduct-ean" type="text" name="ean" value="{{ product.ean or '' }}" /><br/>
<label class="form-label" for="modproduct-ean">EAN code: </label>
<input class="form-control" id="modproduct-ean" type="text" name="ean" value="{{ product.ean or '' }}" /><br/>
<label for="modproduct-price-member">Member price: </label>
CHF <input id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /><br/>
<label class="form-label" for="modproduct-price-member">Member price: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" />
</div>
<label for="modproduct-price-non-member">Non-member price: </label>
CHF <input id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" /><br/>
<label class="form-label" for="modproduct-price-non-member">Non-member price: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" />
</div>
<label for="modproduct-custom-price"><abbr title="When 'Custom Price' is enabled, users choose the price to pay, but at least the prices given above">Custom Price</abbr>: </label>
<input id="modproduct-custom-price" type="checkbox" name="custom_price" {% if product.custom_price %} checked="checked" {% endif %} /><br/>
<label for="modproduct-stockable">Stockable: </label>
<input id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} /><br/>
<div class="form-check">
<input class="form-check-input" id="modproduct-custom-price" type="checkbox" name="custom_price" {% if product.custom_price %} checked="checked" {% endif %} />
<label class="form-check-label" for="modproduct-custom-price"><abbr title="When 'Custom Price' is enabled, users choose the price to pay, but at least the prices given above">Custom Price</abbr></label>
</div>
<label for="modproduct-balance">Stock: </label>
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<div class="form-check">
<input class="form-check-input" id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} />
<label class="form-check-label" for="modproduct-stockable">Stockable</label>
</div>
<label for="modproduct-image">
<label class="form-label" for="modproduct-balance">Stock: </label>
<input class="form-control" id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<label class="form-label" for="modproduct-image">
<img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Image of {{ product.name }}" />
</label><br/>
<input id="modproduct-image" type="file" name="image" accept="image/*" /><br/>
<input class="form-control" id="modproduct-image" type="file" name="image" accept="image/*" /><br/>
<input id="modproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input type="submit" value="Save changes">
<input class="btn btn-primary" type="submit" value="Save changes">
</form>
<h2>Delete Product</h2>
<form id="modproduct-delproduct-form" method="post" action="/modproduct?change=del" accept-charset="UTF-8">
<input id="modproduct-delproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input type="submit" value="Delete product" />
<input class="btn btn-danger" type="submit" value="Delete product {{ product.name }}" />
</form>
</section>

View file

@ -1,27 +1,22 @@
{% extends "base.html" %}
{% block header %}
<h1>Administration</h1>
{{ super() }}
{% endblock %}
{% block main %}
<section id="moduser-account">
<h2>Modify {{ user.name }}</h2>
<h1>Modify {{ user.name }}</h1>
<form id="moduser-account-form" method="post" action="/moduser?change=update" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="moduser-account-username">Username: </label>
<input id="moduser-account-username" type="text" name="username" value="{{ user.name }}" /><br/>
<label class="form-label" for="moduser-account-username">Username: </label>
<input class="form-control" id="moduser-account-username" type="text" name="username" value="{{ user.name }}" /><br/>
<label for="moduser-account-email">E-Mail: </label>
<input id="moduser-account-email" type="text" name="email" value="{% if user.email is not none %}{{ user.email }}{% endif %}" /><br/>
<label class="form-label" for="moduser-account-email">E-Mail: </label>
<input class="form-control" id="moduser-account-email" type="text" name="email" value="{% if user.email is not none %}{{ user.email }}{% endif %}" /><br/>
<label for="moduser-account-password">Password: </label>
<input id="moduser-account-password" type="password" name="password" /><br/>
<label class="form-label" for="moduser-account-password">Password: </label>
<input class="form-control" id="moduser-account-password" type="password" name="password" /><br/>
<label for="moduser-account-receipt-pref">Receipts: </label>
<select id="moduser-account-receipt-pref" name="receipt_pref">
<label class="form-label" for="moduser-account-receipt-pref">Receipts: </label>
<select class="form-select" id="moduser-account-receipt-pref" name="receipt_pref">
{% for pref in receipt_preference_class %}
<option value="{{ pref.value }}" {% if user.receipt_pref == pref %} selected="selected" {% endif %}>{{ pref.human_readable }}</option>
{% endfor %}
@ -29,34 +24,43 @@
{% if config_smtp_enabled != '1' %}Sending receipts is disabled by your administrator.{% endif %}
<br/>
<label for="moduser-account-ismember">Member: </label>
<input id="moduser-account-ismember" name="ismember" type="checkbox" {% if user.is_member %} checked="checked" {% endif %}/><br/>
<div class="form-check">
<input class="form-check-input" id="moduser-account-ismember" name="ismember" type="checkbox" {% if user.is_member %} checked="checked" {% endif %}>
<label class="form-check-label" for="moduser-account-ismember">Member</label>
</div>
<label for="moduser-account-isadmin">Admin: </label>
<input id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/><br/>
<div class="form-check">
<input class="form-check-input" id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/>
<label class="form-check-label" for="moduser-account-isadmin">Admin</label>
</div>
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if user.logout_after_purchase %} checked="checked" {% endif %}/><br/>
<div class="form-check">
<input class="form-check-input" id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if user.logout_after_purchase %} checked="checked" {% endif %}/>
<label class="form-check-label" for="admin-myaccount-logout-after-purchase">Logout after purchase</label>
</div>
<label for="moduser-account-balance">Balance: </label>
CHF <input id="moduser-account-balance" name="balance" type="number" step="0.01" value="{{ user.balance|chf(False) }}" /><br/>
<label class="form-label" for="moduser-account-balance">Balance: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="moduser-account-balance" name="balance" type="number" step="0.01" value="{{ user.balance|chf(False) }}" />
</div>
<label for="moduser-account-balance-reason">Reason for balance modification: </label>
<input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
<label class="form-label" for="moduser-account-balance-reason">Reason for balance modification: </label>
<input class="form-control" id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
<label for="moduser-account-avatar">
<label class="form-label" for="moduser-account-avatar">
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" />
</label><br/>
<input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input class="form-control" id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input id="moduser-account-userid" type="hidden" name="userid" value="{{ user.id }}" /><br/>
<input type="submit" value="Save changes">
<input class="btn btn-primary" type="submit" value="Save changes">
</form>
<h2>Tokens</h2>
<table border="1">
<table class="table table-striped">
<tr>
<th>Token</th>
<th>Name</th>
@ -68,14 +72,16 @@
<td>••••••••</td>
<td>{{ token.name }}</td>
<td>{{ token.date }}</td>
<td><a style="text-decoration: none; color: #ff0000;" href="/moduser?change=deltoken&token={{ token.id }}">🗑</a></td>
<td><a class="btn btn-danger" href="/moduser?change=deltoken&token={{ token.id }}">Delete</a></td>
</tr>
{% endfor %}
</table>
<h2>Delete Account</h2>
<form id="moduser-deluser-form" method="post" action="/moduser?change=del" accept-charset="UTF-8">
<input id="moduser-deluser-userid" type="hidden" name="userid" value="{{ user.id }}" /><br/>
<input type="submit" value="Delete user" />
<input class="btn btn-danger" type="submit" value="Delete user account {{ user.name }}" />
</form>
</section>

View file

@ -1,29 +1,17 @@
{% extends "base.html" %}
{% block header %}
{# Show the username. #}
<h1>Welcome, {{ authuser.name }}</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Welcome, {{ authuser.name }}</h1>
{# Show the users current balance #}
Your balance: {{ authuser.balance|chf }}
<br/>
{# Logout link #}
<div class="thumblist-item">
<a href="/logout">Logout</a>
</div>
{# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #}
<div id="depositlist">
<div class="thumblist-item">
<a href="/deposit?n=100">Deposit CHF 1</a>
</div>
<div class="thumblist-item">
<a href="/deposit?n=1000">Deposit CHF 10</a>
</div>
</div>
<p>
Your balance: <strong>{{ authuser.balance|chf }}</strong>
</p>
<p id="depositlist">
<a class="btn btn-primary me-2" href="/deposit?n=100">Deposit CHF 1</a>
<a class="btn btn-primary me-2" href="/deposit?n=1000">Deposit CHF 10</a>
</p>
<div id="deposit-wrapper">
<div id="deposit-input">
<div id="deposit-output">
@ -47,37 +35,42 @@
<script src="/static/js/depositlist.js"></script>
<br/>
<div class="row itemlist">
{% for product in products %}
{# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #}
<div class="thumblist-item">
<div class="col-sm-1 g-4">
{% if product.custom_price %}
<a onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
<a class="card h-100 text-bg-light" onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
{% else %}
<a {% if product.ean %}id="a-buy-ean{{ product.ean }}"{% endif %} href="/buy?pid={{ product.id }}">
<a class="card h-100 text-bg-light" {% if product.ean %}id="a-buy-ean{{ product.ean }}"{% endif %} href="/buy?pid={{ product.id }}">
{% endif %}
<span class="thumblist-title">{{ product.name }}</span>
<div class="card-header">
{{ product.name }}
</div>
<div class="card-body">
{% if product.custom_price %}
<span class="thumblist-detail">Custom Price</span><br/>
<span class="card-text">Custom Price</span>
{% else %}
<span class="thumblist-detail">Price:
<span class="card-text">
{% if authuser.is_member %}
{{ product.price_member|chf }}
{% else %}
{{ product.price_non_member|chf }}
{% endif %}
</span><br/>
{% endif %}
<div class="imgcontainer">
<img src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ product.name }}" draggable="false"/>
{% set pstock = stock.get_stock(product) %}
{% if pstock is not none %}
<span class="thumblist-stock">{{ pstock }}</span>
</span>
{% endif %}
</div>
<img class="card-img-bottom" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ product.name }}" draggable="false"/>
{% set pstock = stock.get_stock(product) %}
{% if pstock is not none %}
<div class="card-img-overlay d-flex flex-column justify-content-end">
<span class="card-text text-bg-light">{{ pstock }}</span>
</div>
{% endif %}
</a>
</div>
{% endfor %}
<br/>
</div>
{{ super() }}

152
templates/settings.html Normal file
View file

@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block main %}
<h1>Settings</h1>
<ul class="nav nav-tabs" id="settingsTabContent" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="settings-account-tab" data-bs-toggle="tab" data-bs-target="#settings-account-tab-pane" type="button" role="tab" aria-controls="settings-account-tab-pane" aria-selected="true">My Account</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-password-tab" data-bs-toggle="tab" data-bs-target="#settings-password-tab-pane" type="button" role="tab" aria-controls="settings-password-tab-pane" aria-selected="false">Password</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-touchkey-tab" data-bs-toggle="tab" data-bs-target="#settings-touchkey-tab-pane" type="button" role="tab" aria-controls="settings-touchkey-tab-pane" aria-selected="false">Touchkey</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-tokens-tab" data-bs-toggle="tab" data-bs-target="#settings-tokens-tab-pane" type="button" role="tab" aria-controls="settings-tokens-tab-pane" aria-selected="false">Tokens</button>
</li>
</ul>
<div class="tab-content" id="settingsTabContent">
<section class="tab-pane fade pt-3 show active" id="settings-account-tab-pane" role="tabpanel">
<h2>My Account</h2>
<form id="settings-myaccount-form" method="post" action="/settings?change=account" accept-charset="UTF-8">
<label class="form-label" for="settings-myaccount-username">Username: </label>
<input class="form-control" id="settings-myaccount-username" type="text" name="username" value="{{ authuser.name }}" /><br/>
<label class="form-label" for="settings-myaccount-email">E-Mail: </label>
<input class="form-control" id="settings-myaccount-email" type="text" name="email" value="{% if authuser.email is not none %}{{ authuser.email }}{% endif %}" /><br/>
<label class="form-label" for="settings-myaccount-receipt-pref">Receipts: </label>
<select class="form-select" id="settings-myaccount-receipt-pref" name="receipt_pref">
{% for pref in receipt_preference_class %}
<option value="{{ pref.value }}" {% if authuser.receipt_pref == pref %} selected="selected" {% endif %}>{{ pref.human_readable }}</option>
{% endfor %}
</select>
{% if config_smtp_enabled != '1' %}Sending receipts is disabled by your administrator.{% endif %}
<br/>
<div class="form-check">
<input class="form-check-input" id="settings-myaccount-ismember" type="checkbox" disabled="disabled" {% if authuser.is_member %} checked="checked" {% endif %}/>
<label class="form-check-label" for="settings-myaccount-ismember">Member</label>
</div>
<div class="form-check">
<input class="form-check-input" id="settings-myaccount-isadmin" type="checkbox" disabled="disabled" {% if authuser.is_admin %} checked="checked" {% endif %}/>
<label class="form-check-label" for="settings-myaccount-isadmin">Admin</label>
</div>
<div class="form-check">
<input class="form-check-input" id="settings-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if authuser.logout_after_purchase %} checked="checked" {% endif %}/>
<label class="form-check-label" for="settings-myaccount-logout-after-purchase">Logout after purchase</label>
</div>
<input class="btn btn-primary" type="submit" value="Save changes" />
</form>
<h2>Avatar</h2>
<form id="settings-avatar-form" method="post" action="/settings?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
<img src="/static/upload/thumbnails/users/{{ authuser.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ authuser.name }}" /><br/>
<label class="form-label" for="settings-avatar-avatar">Upload new file: </label>
<input class="form-control" id="settings-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input class="btn btn-primary" type="submit" value="Save changes" />
</form>
</section>
<section class="tab-pane fade pt-3" id="settings-password-tab-pane" role="tabpanel">
<h2>Password</h2>
<form id="settings-password-form" method="post" action="/settings?change=password" accept-charset="UTF-8">
<label class="form-label" for="settings-password-oldpass">Current password: </label>
<input class="form-control" id="settings-password-oldpass" type="password" name="oldpass" /><br/>
<label class="form-label" for="settings-password-newpass">New password: </label>
<input class="form-control" id="settings-password-newpass" type="password" name="newpass" /><br/>
<label class="form-label" for="settings-password-newpass2">Repeat password: </label>
<input class="form-control" id="settings-password-newpass2" type="password" name="newpass2" /><br/>
<input class="btn btn-primary" type="submit" value="Save changes" />
</form>
</section>
<section class="tab-pane fade pt-3" id="settings-touchkey-tab-pane" role="tabpanel">
<h2>Touchkey</h2>
<form id="settings-touchkey-form" method="post" action="/settings?change=touchkey" accept-charset="UTF-8">
Draw a new touchkey (leave empty to disable):
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="settings-touchkey-touchkey" type="hidden" name="touchkey" value="" />
<input class="btn btn-primary" type="submit" value="Save changes" />
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'settings-touchkey-touchkey');
</script>
</section>
<section class="tab-pane fade pt-3" id="settings-tokens-tab-pane" role="tabpanel">
<h2>Tokens</h2>
<strong>Warning:</strong>
Login tokens are a convenience feature that if used may weaken security.
Make sure you only use tokens not easily accessible to other people.
<form id="settings-newtoken-form" method="post" action="/settings?change=addtoken" accept-charset="UTF-8">
<table class="table table-striped">
<tr>
<th>Token</th>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="settings-newtoken-token" type="password" name="token" value="" placeholder="Scan to insert EAN"></td>
<td><input class="form-control" id="settings-newtoken-name" type="text" name="name" value="" placeholder="New token name"></td>
<td></td>
<td><input class="btn btn-success" type="submit" value="Create Token"></td>
</tr>
{% for token in tokens %}
<tr>
<td>••••••••</td>
<td>{{ token.name }}</td>
<td>{{ token.date }}</td>
<td><a class="btn btn-danger" href="/settings?change=deltoken&token={{ token.id }}">Delete</a></td>
</tr>
{% endfor %}
</table>
</form>
</section>
{{ super() }}
{% endblock %}
{% block eanwebsocket %}
let tokeninput = document.getElementById("settings-newtoken-token");
tokeninput.value = e.data;
tokeninput.select();
tokeninput.scrollIntoView();
{% endblock %}

View file

@ -1,42 +1,36 @@
{% extends "base.html" %}
{% block header %}
<h1>Signup</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Signup</h1>
{# Show a username/password signup form #}
<form method="post" action="/signup" id="signupform" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="signup-username"><b>Username</b>: </label>
<input id="signup-username" type="text" name="username" required="required"/><br/>
<label class="form-label" for="signup-username"><b>Username</b>: </label>
<input class="form-control" id="signup-username" type="text" name="username" required="required"/><br/>
<label for="signup-password"><b>Choose a password</b>: </label>
<input id="signup-password" type="password" name="password" required="required"/><br/>
<label class="form-label" for="signup-password"><b>Choose a password</b>: </label>
<input class="form-control" id="signup-password" type="password" name="password" required="required"/><br/>
<label for="signup-password2"><b>Repeat password</b>: </label>
<input id="signup-password2" type="password" name="password2" required="required"/><br/>
<label class="form-label" for="signup-password2"><b>Repeat password</b>: </label>
<input class="form-control" id="signup-password2" type="password" name="password2" required="required"/><br/>
<label for="signup-email">E-Mail: </label>
<input id="signup-email" type="text" name="email"/><br/>
<label class="form-label" for="signup-email">E-Mail: </label>
<input class="form-control" id="signup-email" type="text" name="email"/><br/>
<label for="signup-avatar">Upload a profile picture: </label>
<input id="signup-avatar" type="file" name="avatar" accept="image/*" /><br/>
<label class="form-label" for="signup-avatar">Upload a profile picture: </label>
<input class="form-control" id="signup-avatar" type="file" name="avatar" accept="image/*" /><br/>
<label for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<label class="form-label" for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="signup-touchkey" type="hidden" name="touchkey" value="" />
<input type="submit" value="Create account">
<input class="btn btn-primary" type="submit" value="Create account">
<a class="btn btn-secondary" href="/">Cancel</a>
</form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');

View file

@ -1,121 +1,34 @@
{% extends "base.html" %}
{% block header %}
<h1>Signup</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Signup</h1>
{# Show a username/password signup form #}
<form method="post" action="/signup" id="signupform" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="signup-username"><b>Username</b>: </label>
<input id="signup-username" type="text" name="username" required="required" class="osk-target"/><br/>
<label class="form-label" for="signup-username">Username:</label>
<input class="form-control" id="signup-username" type="text" name="username" required="required" class="osk-target"/><br/>
<label for="signup-password"><b>Choose a password</b>: </label>
<input id="signup-password" type="password" name="password" required="required" class="osk-target"/><br/>
<label class="form-label" for="signup-password">Choose a password:</label>
<input class="form-control" id="signup-password" type="password" name="password" required="required" class="osk-target"/><br/>
<label for="signup-password2"><b>Repeat password</b>: </label>
<input id="signup-password2" type="password" name="password2" required="required" class="osk-target"/><br/>
<label class="form-label" for="signup-password2">Repeat password:</label>
<input class="form-control" id="signup-password2" type="password" name="password2" required="required" class="osk-target"/><br/>
<label for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<label class="form-label" for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="signup-touchkey" type="hidden" name="touchkey" value="" />
<input type="submit" value="Create account">
<input class="btn btn-primary" type="submit" value="Create account">
<a class="btn btn-secondary" href="/">Cancel</a>
</form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
<div id="osk-kbd" class="osk osk-kbd">
{% set lower = [['1','2','3','4','5','6','7','8','9','0','-','⌫'],
['q','w','e','r','t','y','u','i','o','p','[','⇥'],
['a','s','d','f','g','h','j','k','l',';','\'','⇤'],
['z','x','c','v','b','n','m',',','.','/','{','⇧'],
['SPACE']] %}
{% set upper = [['!','@','#','$','%','^','&','*','(',')','_','⌫'],
['Q','W','E','R','T','Y','U','I','O','P',']','⇥'],
['A','S','D','F','G','H','J','K','L',':','"','⇤'],
['Z','X','C','V','B','N','M','<','>','?','}','⇧'],
['SPACE']] %}
{% for lrow, urow in zip(lower, upper) %}
<div class="osk osk-kbd-row">
{% for lc, uc in zip(lrow, urow) %}
<div tabindex="1000" class="osk osk-button{% if lc == 'SPACE' %} osk-button-space{% endif %}" data-lowercase="{{ lc }}" data-uppercase="{{ uc }}">{{ lc }}</div>
{% endfor %}
</div>
{% endfor %}
</div>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
</script>
<script>
let lastfocus = null;
let shift = 0;
let osk = document.getElementById('osk-kbd');
let inputs = [].slice.call(document.getElementsByClassName('osk-target'));
let oskButtons = document.getElementsByClassName('osk-button');
for (let i = 0; i < inputs.length; ++i) {
inputs[i].onfocus = () => {
osk.classList.add('visible');
}
inputs[i].onblur = (blur) => {
if (blur.relatedTarget !== undefined && blur.relatedTarget !== null && blur.relatedTarget.classList.contains('osk')) {
lastfocus = blur.target;
} else {
lastfocus = null;
if (blur.relatedTarget === undefined || blur.relatedTarget === null || !blur.relatedTarget.classList.contains('osk-target')) {
osk.classList.remove('visible');
}
}
}
}
for (let i = 0; i < oskButtons.length; ++i) {
oskButtons[i].onclick = (click) => {
if (lastfocus === null || lastfocus === undefined) {
return;
}
let btn = click.target.innerText;
let idx = inputs.indexOf(lastfocus);
switch (btn) {
case '⇥':
lastfocus = inputs[(idx + 1) % inputs.length];
break;
case '⇤':
lastfocus = inputs[(((idx - 1) % inputs.length) + inputs.length) % inputs.length];
break;
case '⌫':
if (lastfocus.value.length > 0) {
lastfocus.value = lastfocus.value.substring(0, lastfocus.value.length-1);
}
break;
case 'SPACE':
lastfocus.value += ' ';
break;
case '⇧':
shift = !shift;
click.target.classList.toggle('osk-locked');
for (let j = 0; j < oskButtons.length; ++j) {
oskButtons[j].innerText = oskButtons[j].getAttribute(shift ? 'data-uppercase' : 'data-lowercase');
}
break;
default: {
lastfocus.value += btn;
break;
}
}
lastfocus.focus();
}
}
</script>
{{ super() }}
{% endblock %}

View file

@ -1,8 +1,6 @@
{% extends "base.html" %}
{% block header %}
{# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #}
<h1>{{ setupname|safe }} Sales Statistics</h1>
<style>
@media all {
svg g text {
@ -32,20 +30,22 @@
{% block main %}
<h1>Sales Statistics</h1>
<section id="statistics-range">
<h2>Time Range</h2>
<form action="/statistics" method="get" accept-charset="utf-8">
<label for="statistics-range-from">From:</label>
<input type="date" id="statistics-range-from" name="fromdate" value="{{fromdate}}" />
<label class="form-label" for="statistics-range-from">From:</label>
<input class="form-control" type="date" id="statistics-range-from" name="fromdate" value="{{fromdate}}" />
<span class="input-replacement">{{fromdate}}</span>
<label for="statistics-range-to">To:</label>
<input type="date" id="statistics-range-to" name="todate" value="{{todate}}" />
<label class="form-label" for="statistics-range-to">To:</label>
<input class="form-control" type="date" id="statistics-range-to" name="todate" value="{{todate}}" />
<span class="input-replacement">{{todate}}</span>
<input type="submit" value="Update">
<input class="btn btn-primary" type="submit" value="Update">
</form>
</section>
@ -67,12 +67,18 @@
<h2>Purchases</h2>
<table>
<tr class="head"><td>Product</td><td>Income</td><td>Units</td></tr>
<table class="table table-striped">
<thead>
<tr><th>Product</th><th>Income</th><th>Units</th></tr>
</thead>
<tbody>
{% for prod, data in consumptions.items() %}
<tr class="{{ loop.cycle('odd', '') }}"><td><b>{{ prod }}</b></td><td>{{ -data[0]|chf }}</td><td>{{ data[1] }}</td></tr>
<tr><td>{{ prod }}</td><td>{{ -data[0]|chf }}</td><td>{{ data[1] }}</td></tr>
{% endfor %}
<tr class="foot"><td>Total</td><td>{{ total_income|chf }}</td><td>{{ total_consumption }}</td></tr>
</tbody>
<tfoot>
<tr><td>Total</td><td>{{ total_income|chf }}</td><td>{{ total_consumption }}</td></tr>
</tfoot>
</table>
{# Really hacky pie chart implementation. #}

View file

@ -1,22 +1,9 @@
{% extends "base.html" %}
{% block head %}
{{ super() }}
<style>
svg {
width: 400px;
height: auto;
}
</style>
{% endblock %}
{% block header %}
<h1>Welcome, {{ username }}</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Welcome, {{ username }}</h1>
<svg id="touchkey-svg" width="400" height="400"></svg>
<form method="post" action="/touchkey" id="loginform" accept-charset="UTF-8">
@ -28,9 +15,7 @@
{% endif %}
</form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
<a class="btn btn-secondary" href="/">Cancel</a>
<script src="/static/js/touchkey.js"></script>
<script>

View file

@ -1,26 +1,22 @@
{% extends "base.html" %}
{% block header %}
{# Show the setup name, as set in the config file, as page title followed by "Setup". Don't escape HTML entities. #}
<h1>{{ setupname|safe }} Setup</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Welcome</h1>
{# Show a user creation form #}
Please create an admin user account
<form method="post" action="/userbootstrap" accept-charset="UTF-8">
<label for="username">Username: </label>
<input id="username" type="text" name="username"/><br/>
<label class="form-label" for="username">Username: </label>
<input class="form-control" id="username" type="text" name="username"/><br/>
<label for="password">Password: </label>
<input id="password" type="password" name="password"/><br/>
<label class="form-label" for="password">Password: </label>
<input class="form-control" id="password" type="password" name="password"/><br/>
<label for="password2">Repeat: </label>
<input id="password2" type="password" name="password2"/><br/>
<label class="form-label" for="password2">Repeat: </label>
<input class="form-control" id="password2" type="password" name="password2"/><br/>
<input type="submit" value="Create user">
<input class="btn btn-success" type="submit" value="Create user">
</form>
{{ super() }}

View file

@ -1,35 +1,22 @@
{% extends "base.html" %}
{% block header %}
{# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #}
<h1>{{ setupname|safe }}</h1>
{{ super() }}
{% endblock %}
{% block main %}
<div class="row itemlist">
{% for user in users %}
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
<div class="thumblist-item">
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}{% if buyproduct %}&buypid={{ buyproduct.id }}{% endif %}">
<span class="thumblist-title">{{ user.name }}</span><br/>
<div class="imgcontainer">
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/>
<div class="col-sm-1 g-4">
<a class="card h-100 text-bg-light" href="/touchkey?uid={{ user.id }}&username={{ user.name }}{% if buyproduct %}&buypid={{ buyproduct.id }}{% endif %}">
<div class="card-header">
{{ user.name }}
</div>
<img class="card-img-bottom" src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/>
</a>
</div>
{% endfor %}
</div>
<br/>
{# Link to the password login #}
<div class="thumblist-item">
<a href="/login">Password login</a>
</div>
{% if signup %}
<div class="thumblist-item">
<a href="/signup">Create account</a>
</div>
{% endif %}
{{ super() }}