Added admin panel

* Added API and UI base for panel

* Added option to disable registration (Fixes #27)

* Added settings for login duration (Resolves #39)

* Added user management and download database button
This commit is contained in:
Casvt
2024-01-29 17:30:40 +01:00
committed by GitHub
parent 8ad985278b
commit 634759a964
14 changed files with 1034 additions and 52 deletions

View File

@@ -12,7 +12,8 @@ from waitress.server import create_server
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from backend.db import DBConnection, ThreadedTaskDispatcher, close_db, setup_db
from frontend.api import api, api_prefix, reminder_handler
from frontend.api import (admin_api, admin_api_prefix, api, api_prefix,
reminder_handler)
from frontend.ui import ui
HOST = '0.0.0.0'
@@ -73,6 +74,7 @@ def _create_app() -> Flask:
app.register_blueprint(ui)
app.register_blueprint(api, url_prefix=api_prefix)
app.register_blueprint(admin_api, url_prefix=admin_api_prefix)
# Setup closing database
app.teardown_appcontext(close_db)

View File

@@ -85,3 +85,7 @@ class APIKeyExpired(CustomException):
def __init__(self, e=None) -> None:
return
class NewAccountsNotAllowed(CustomException):
"""It's not allowed to create a new account"""
api_response = {'error': 'NewAccountsNotAllowed', 'result': {}, 'code': 403}

View File

@@ -1,7 +1,7 @@
#-*- coding: utf-8 -*-
from datetime import datetime
import logging
from datetime import datetime
from sqlite3 import Connection, ProgrammingError, Row
from threading import current_thread, main_thread
from time import time
@@ -12,7 +12,7 @@ from waitress.task import ThreadedTaskDispatcher as OldThreadedTaskDispatcher
from backend.custom_exceptions import AccessUnauthorized, UserNotFound
__DATABASE_VERSION__ = 7
__DATABASE_VERSION__ = 8
class Singleton(type):
_instances = {}
@@ -217,11 +217,53 @@ def migrate_db(current_db_version: int) -> None:
""")
current_db_version = 7
if current_db_version == 7:
# V7 -> V8
from backend.settings import _format_setting, default_settings
from backend.users import register_user
cursor.executescript("""
DROP TABLE config;
CREATE TABLE IF NOT EXISTS config(
key VARCHAR(255) PRIMARY KEY,
value BLOB NOT NULL
);
"""
)
cursor.executemany("""
INSERT OR IGNORE INTO config(key, value)
VALUES (?, ?);
""",
map(
lambda kv: (kv[0], _format_setting(*kv)),
default_settings.items()
)
)
cursor.executescript("""
ALTER TABLE users
ADD admin BOOL NOT NULL DEFAULT 0;
UPDATE users
SET username = 'admin_old'
WHERE username = 'admin';
""")
register_user('admin', 'admin')
cursor.execute("""
UPDATE users
SET admin = 1
WHERE username = 'admin';
""")
return
def setup_db() -> None:
"""Setup the database
"""
from backend.settings import (_format_setting, default_settings, get_setting,
set_setting)
cursor = get_db()
cursor.execute("PRAGMA journal_mode = wal;")
@@ -230,7 +272,8 @@ def setup_db() -> None:
id INTEGER PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
salt VARCHAR(40) NOT NULL,
hash VARCHAR(100) NOT NULL
hash VARCHAR(100) NOT NULL,
admin BOOL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS notification_services(
id INTEGER PRIMARY KEY,
@@ -292,26 +335,24 @@ def setup_db() -> None:
);
CREATE TABLE IF NOT EXISTS config(
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL
value BLOB NOT NULL
);
""")
cursor.execute("""
cursor.executemany("""
INSERT OR IGNORE INTO config(key, value)
VALUES ('database_version', ?);
VALUES (?, ?);
""",
(__DATABASE_VERSION__,)
map(
lambda kv: (kv[0], _format_setting(*kv)),
default_settings.items()
)
)
current_db_version = int(cursor.execute(
"SELECT value FROM config WHERE key = 'database_version' LIMIT 1;"
).fetchone()[0])
current_db_version = get_setting('database_version')
logging.debug(f'Current database version {current_db_version} and desired database version {__DATABASE_VERSION__}')
if current_db_version < __DATABASE_VERSION__:
migrate_db(current_db_version)
cursor.execute(
"UPDATE config SET value = ? WHERE key = 'database_version';",
(__DATABASE_VERSION__,)
)
set_setting('database_version', __DATABASE_VERSION__)
return

119
backend/settings.py Normal file
View File

@@ -0,0 +1,119 @@
#-*- coding: utf-8 -*-
from backend.custom_exceptions import InvalidKeyValue, KeyNotFound
from backend.db import __DATABASE_VERSION__, get_db
default_settings = {
'allow_new_accounts': True,
'login_time': 3600,
'login_time_reset': True,
'database_version': __DATABASE_VERSION__
}
def _format_setting(key: str, value):
"""Turn python value in to database value
Args:
key (str): The key of the value
value (Any): The value itself
Raises:
InvalidKeyValue: The value is not valid
Returns:
Any: The converted value
"""
if key == 'database_version':
try:
value = int(value)
except ValueError:
raise InvalidKeyValue(key, value)
elif key in ('allow_new_accounts', 'login_time_reset'):
if not isinstance(value, bool):
raise InvalidKeyValue(key, value)
value = int(value)
elif key == 'login_time':
if not isinstance(value, int) or not 60 <= value <= 2592000:
raise InvalidKeyValue(key, value)
return value
def _reverse_format_setting(key: str, value):
"""Turn database value in to python value
Args:
key (str): The key of the value
value (Any): The value itself
Returns:
Any: The converted value
"""
if key in ('allow_new_accounts', 'login_time_reset'):
value = value == 1
return value
def get_setting(key: str):
"""Get a value from the config
Args:
key (str): The key of which to get the value
Raises:
KeyNotFound: Key is not in config
Returns:
Any: The value of the key
"""
result = get_db().execute(
"SELECT value FROM config WHERE key = ? LIMIT 1;",
(key,)
).fetchone()
if result is None:
raise KeyNotFound(key)
result = _reverse_format_setting(key, result[0])
return result
def get_admin_settings() -> dict:
"""Get all admin settings
Returns:
dict: The admin settings
"""
return dict((
(key, _reverse_format_setting(key, value))
for (key, value) in get_db().execute("""
SELECT key, value
FROM config
WHERE
key = 'allow_new_accounts'
OR key = 'login_time'
OR key = 'login_time_reset';
"""
)
))
def set_setting(key: str, value) -> None:
"""Set a value in the config
Args:
key (str): The key for which to set the value
value (Any): The value to give to the key
Raises:
KeyNotFound: The key is not in the config
InvalidKeyValue: The value is not allowed for the key
"""
if not key in (*default_settings, 'database_version'):
raise KeyNotFound(key)
value = _format_setting(key, value)
get_db().execute(
"UPDATE config SET value = ? WHERE key = ?;",
(value, key)
)
return

View File

@@ -1,25 +1,29 @@
#-*- coding: utf-8 -*-
import logging
from backend.custom_exceptions import (AccessUnauthorized, UsernameInvalid,
from typing import List, Union
from backend.custom_exceptions import (AccessUnauthorized,
NewAccountsNotAllowed, UsernameInvalid,
UsernameTaken, UserNotFound)
from backend.db import get_db
from backend.notification_service import NotificationServices
from backend.reminders import Reminders
from backend.security import generate_salt_hash, get_hash
from backend.settings import get_setting
from backend.static_reminders import StaticReminders
from backend.templates import Templates
ONEPASS_USERNAME_CHARACTERS = 'abcedfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.!@$'
ONEPASS_INVALID_USERNAMES = ['reminders','api']
ONEPASS_INVALID_USERNAMES = ['reminders', 'api']
class User:
"""Represents an user account
"""
def __init__(self, username: str, password: str):
def __init__(self, username: str, password: Union[str, None]=None):
# Fetch data of user to check if user exists and to check if password is correct
result = get_db(dict).execute(
"SELECT id, salt, hash FROM users WHERE username = ? LIMIT 1;",
"SELECT id, salt, hash, admin FROM users WHERE username = ? LIMIT 1;",
(username,)
).fetchone()
if not result:
@@ -27,11 +31,13 @@ class User:
self.username = username
self.salt = result['salt']
self.user_id = result['id']
self.admin = result['admin'] == 1
# Check password
hash_password = get_hash(result['salt'], password)
if not hash_password == result['hash']:
raise AccessUnauthorized
if password is not None:
hash_password = get_hash(result['salt'], password)
if not hash_password == result['hash']:
raise AccessUnauthorized
@property
def reminders(self) -> Reminders:
@@ -123,22 +129,29 @@ def _check_username(username: str) -> None:
raise UsernameInvalid
return
def register_user(username: str, password: str) -> int:
def register_user(username: str, password: str, from_admin: bool=False) -> int:
"""Add a user
Args:
username (str): The username of the new user
password (str): The password of the new user
from_admin (bool, optional): Skip check if new accounts are allowed.
Defaults to False.
Raises:
UsernameInvalid: Username not allowed or contains invalid characters
UsernameTaken: Username is already taken; usernames must be unique
NewAccountsNotAllowed: In the admin panel, new accounts are set to be
not allowed.
Returns:
user_id (int): The id of the new user. User registered successful
"""
logging.info(f'Registering user with username {username}')
if not from_admin and not get_setting('allow_new_accounts'):
raise NewAccountsNotAllowed
# Check if username is valid
_check_username(username)
@@ -165,3 +178,46 @@ def register_user(username: str, password: str) -> int:
logging.debug(f'Newly registered user has id {user_id}')
return user_id
def get_users() -> List[dict]:
"""Get all user info for the admin
Returns:
List[dict]: The info about all users
"""
result = [
dict(u)
for u in get_db(dict).execute(
"SELECT id, username, admin FROM users ORDER BY username;"
)
]
return result
def edit_user_password(id: int, new_password: str) -> None:
"""Change the password of a user for the admin
Args:
id (int): The ID of the user to change the password of
new_password (str): The new password to set for the user
"""
username = (get_db().execute(
"SELECT username FROM users WHERE id = ? LIMIT 1;",
(id,)
).fetchone() or [''])[0]
User(username).edit_password(new_password)
return
def delete_user(id: int) -> None:
"""Delete a user for the admin
Args:
id (int): The ID of the user to delete
"""
username = (get_db().execute(
"SELECT username FROM users WHERE id = ? LIMIT 1;",
(id,)
).fetchone() or [''])[0]
if username == 'admin':
raise UserNotFound
User(username).delete()
return

View File

@@ -1,5 +1,7 @@
#-*- coding: utf-8 -*-
from io import BytesIO
import logging
from abc import ABC, abstractmethod
from os import urandom
from re import compile
@@ -7,24 +9,29 @@ from time import time as epoch_time
from typing import Any, Callable, Dict, List, Tuple, Union
from apprise import Apprise
from flask import Blueprint, g, request
from flask import Blueprint, g, request, send_file
from flask.scaffold import T_route
from backend.custom_exceptions import (AccessUnauthorized, APIKeyExpired,
APIKeyInvalid, InvalidKeyValue,
InvalidTime, KeyNotFound,
NewAccountsNotAllowed,
NotificationServiceInUse,
NotificationServiceNotFound,
ReminderNotFound, TemplateNotFound,
UsernameInvalid, UsernameTaken,
UserNotFound)
from backend.db import DBConnection
from backend.notification_service import (NotificationService,
NotificationServices,
get_apprise_services)
from backend.reminders import Reminders, reminder_handler
from backend.settings import (_format_setting, get_admin_settings, get_setting,
set_setting)
from backend.static_reminders import StaticReminders
from backend.templates import Template, Templates
from backend.users import User, register_user
from backend.users import (User, delete_user, edit_user_password, get_users,
register_user)
#===================
# Input validation
@@ -111,7 +118,11 @@ class NewPasswordVariable(PasswordVariable):
related_exceptions = [KeyNotFound]
class UsernameCreateVariable(UsernameVariable):
related_exceptions = [KeyNotFound, UsernameInvalid, UsernameTaken]
related_exceptions = [
KeyNotFound,
UsernameInvalid, UsernameTaken,
NewAccountsNotAllowed
]
class PasswordCreateVariable(PasswordVariable):
related_exceptions = [KeyNotFound]
@@ -254,7 +265,7 @@ class ColorVariable(DefaultInputVariable):
default = None
related_exceptions = [InvalidKeyValue]
def validate(self) -> None:
def validate(self) -> bool:
return self.value is None or color_regex.search(self.value)
class QueryVariable(DefaultInputVariable):
@@ -262,6 +273,30 @@ class QueryVariable(DefaultInputVariable):
description = 'The search term'
source = DataSource.VALUES
class AdminSettingsVariable(DefaultInputVariable):
related_exceptions = [KeyNotFound, InvalidKeyValue]
def validate(self) -> bool:
try:
_format_setting(self.name, self.value)
except InvalidKeyValue:
return False
return True
class AllowNewAccountsVariable(AdminSettingsVariable):
name = 'allow_new_accounts'
description = ('Whether or not to allow users to register a new account. '
+ 'The admin can always add a new account.')
class LoginTimeVariable(AdminSettingsVariable):
name = 'login_time'
description = ('How long a user stays logged in, in seconds. '
+ 'Between 1 min and 1 month (60 <= sec <= 2592000)')
class LoginTimeResetVariable(AdminSettingsVariable):
name = 'login_time_reset'
description = 'If the Login Time timer should reset with each API request.'
def input_validation() -> Union[None, Dict[str, Any]]:
"""Checks, extracts and transforms inputs
@@ -274,9 +309,20 @@ def input_validation() -> Union[None, Dict[str, Any]]:
Otherwise `Dict[str, Any]` with the input variables, checked and formatted.
"""
inputs = {}
input_variables = api_docs[request.url_rule.rule.split(api_prefix)[1]]['input_variables']
input_variables: Dict[str, List[Union[List[InputVariable], str]]]
if request.path.startswith(admin_api_prefix):
input_variables = api_docs[
_admin_api_prefix + request.url_rule.rule.split(admin_api_prefix)[1]
]['input_variables']
else:
input_variables = api_docs[
request.url_rule.rule.split(api_prefix)[1]
]['input_variables']
if not input_variables:
return
if input_variables.get(request.method) is None:
return inputs
@@ -290,8 +336,11 @@ def input_validation() -> Union[None, Dict[str, Any]]:
):
raise KeyNotFound(input_variable.name)
input_value = given_variables[input_variable.source].get(input_variable.name, input_variable.default)
input_value = given_variables[input_variable.source].get(
input_variable.name,
input_variable.default
)
if not input_variable(input_value).validate():
raise InvalidKeyValue(input_variable.name, input_value)
@@ -313,19 +362,37 @@ class APIBlueprint(Blueprint):
**options: Any
) -> Callable[[T_route], T_route]:
api_docs[rule] = {
'endpoint': rule,
if self == api:
processed_rule = rule
elif self == admin_api:
processed_rule = _admin_api_prefix + rule
else:
raise NotImplementedError
api_docs[processed_rule] = {
'endpoint': processed_rule,
'description': description,
'requires_auth': requires_auth,
'methods': options['methods'],
'input_variables': {k: v[0] for k, v in input_variables.items() if v and v[0]},
'method_descriptions': {k: v[1] for k, v in input_variables.items() if v and len(v) == 2 and v[1]}
'input_variables': {
k: v[0]
for k, v in input_variables.items()
if v and v[0]
},
'method_descriptions': {
k: v[1]
for k, v in input_variables.items()
if v and len(v) == 2 and v[1]
}
}
return super().route(rule, **options)
api_prefix = "/api"
_admin_api_prefix = '/admin'
admin_api_prefix = api_prefix + _admin_api_prefix
api = APIBlueprint('api', __name__)
admin_api = APIBlueprint('admin_api', __name__)
api_key_map = {}
def return_api(result: Any, error: str=None, code: int=200) -> Tuple[dict, int]:
@@ -341,12 +408,31 @@ def auth() -> None:
hashed_api_key = hash(request.values.get('api_key',''))
if not hashed_api_key in api_key_map:
raise APIKeyInvalid
if not (
(
api_key_map[hashed_api_key]['user_data'].admin
and request.path.startswith((admin_api_prefix, api_prefix + '/auth'))
)
or
(
not api_key_map[hashed_api_key]['user_data'].admin
and not request.path.startswith(admin_api_prefix)
)
):
raise APIKeyInvalid
exp = api_key_map[hashed_api_key]['exp']
if exp <= epoch_time():
raise APIKeyExpired
# Api key valid
if get_setting('login_time_reset'):
api_key_map[hashed_api_key]['exp'] = exp = (
epoch_time() + get_setting('login_time')
)
g.hashed_api_key = hashed_api_key
g.exp = exp
g.user_data = api_key_map[hashed_api_key]['user_data']
@@ -354,7 +440,14 @@ def auth() -> None:
def endpoint_wrapper(method: Callable) -> Callable:
def wrapper(*args, **kwargs):
requires_auth = api_docs[request.url_rule.rule.split(api_prefix)[1]]['requires_auth']
if request.path.startswith(admin_api_prefix):
requires_auth = api_docs[
_admin_api_prefix + request.url_rule.rule.split(admin_api_prefix)[1]
]['requires_auth']
else:
requires_auth = api_docs[
request.url_rule.rule.split(api_prefix)[1]
]['requires_auth']
try:
if requires_auth:
auth()
@@ -371,7 +464,8 @@ def endpoint_wrapper(method: Callable) -> Callable:
NotificationServiceInUse, InvalidTime,
KeyNotFound, InvalidKeyValue,
APIKeyInvalid, APIKeyExpired,
TemplateNotFound) as e:
TemplateNotFound,
NewAccountsNotAllowed) as e:
return return_api(**e.api_response)
wrapper.__name__ = method.__name__
@@ -400,7 +494,8 @@ def api_login(inputs: Dict[str, str]):
if not hashed_api_key in api_key_map:
break
exp = epoch_time() + 3600
login_time = get_setting('login_time')
exp = epoch_time() + login_time
api_key_map.update({
hashed_api_key: {
'exp': exp,
@@ -408,7 +503,7 @@ def api_login(inputs: Dict[str, str]):
}
})
result = {'api_key': api_key, 'expires': exp}
result = {'api_key': api_key, 'expires': exp, 'admin': user.admin}
return return_api(result, code=201)
@api.route(
@@ -430,7 +525,8 @@ def api_logout():
def api_status():
result = {
'expires': api_key_map[g.hashed_api_key]['exp'],
'username': api_key_map[g.hashed_api_key]['user_data'].username
'username': api_key_map[g.hashed_api_key]['user_data'].username,
'admin': api_key_map[g.hashed_api_key]['user_data'].admin
}
return return_api(result)
@@ -449,7 +545,7 @@ def api_status():
def api_add_user(inputs: Dict[str, str]):
register_user(inputs['username'], inputs['password'])
return return_api({}, code=201)
@api.route(
'/user',
'Manage a user account',
@@ -780,3 +876,101 @@ def api_get_static_reminder(inputs: Dict[str, Any], s_id: int):
elif request.method == 'DELETE':
reminders.fetchone(s_id).delete()
return return_api({})
#===================
# Admin panel endpoints
#===================
@api.route(
'/settings',
'Get the admin settings',
requires_auth=False,
methods=['GET']
)
@endpoint_wrapper
def api_settings():
return return_api(get_admin_settings())
@admin_api.route(
'/settings',
'Interact with the admin settings',
{'GET': [[],
'Get the admin settings'],
'PUT': [[AllowNewAccountsVariable, LoginTimeVariable,
LoginTimeResetVariable],
'Edit the admin settings']},
methods=['GET', 'PUT']
)
@endpoint_wrapper
def api_admin_settings(inputs: Dict[str, Any]):
if request.method == 'GET':
return return_api(get_admin_settings())
elif request.method == 'PUT':
values = {
'allow_new_accounts': inputs['allow_new_accounts'],
'login_time': inputs['login_time'],
'login_time_reset': inputs['login_time_reset']
}
logging.info(f'Submitting admin settings: {values}')
for k, v in values.items():
set_setting(k, v)
return return_api({})
@admin_api.route(
'/users',
'Get all users or add one',
{'GET': [[],
'Get all users'],
'POST': [[UsernameCreateVariable, PasswordCreateVariable],
'Add a new user']},
methods=['GET', 'POST']
)
@endpoint_wrapper
def api_admin_users(inputs: Dict[str, Any]):
if request.method == 'GET':
result = get_users()
return return_api(result)
elif request.method == 'POST':
register_user(inputs['username'], inputs['password'], True)
return return_api({}, code=201)
@admin_api.route(
'/users/<int:u_id>',
'Manage a specific user',
{'PUT': [[NewPasswordVariable],
'Change the password of the user account'],
'DELETE': [[],
'Delete the user account']},
methods=['PUT', 'DELETE']
)
@endpoint_wrapper
def api_admin_user(inputs: Dict[str, Any], u_id: int):
if request.method == 'PUT':
edit_user_password(u_id, inputs['new_password'])
return return_api({})
elif request.method == 'DELETE':
delete_user(u_id)
for key, value in api_key_map.items():
if value['user_data'].user_id == u_id:
del api_key_map[key]
break
return return_api({})
@admin_api.route(
'/database',
'Download the database',
{'GET': [[]]},
methods=['GET']
)
@endpoint_wrapper
def api_admin_database():
with open(DBConnection.file, 'rb') as database_file:
return send_file(
BytesIO(database_file.read()),
'application/x-sqlite3',
download_name='MIND.db'
), 200

View File

@@ -0,0 +1,259 @@
main {
position: relative;
}
.action-buttons {
--spacing: .5rem;
position: absolute;
margin: var(--spacing);
inset: 0 0 auto 0;
height: var(--nav-width);
display: flex;
justify-content: center;
align-items: center;
gap: calc(var(--spacing) * 3);
padding: var(--spacing);
border-radius: 4px;
background-color: var(--color-gray);
}
.action-buttons > button {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: .5rem;
border-radius: 4px;
background-color: var(--color-dark);
color: var(--color-light);
transition: background-color .1s ease-in-out;
}
.action-buttons > button:hover {
background-color: var(--color-gray);
}
.action-buttons > button > svg {
height: 1.8rem;
width: 2rem;
}
.form-container {
height: calc(100vh - var(--header-height));
overflow-y: auto;
padding: .5rem;
padding-top: calc(1rem + var(--nav-width));
}
#settings-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
h2 {
width: 100%;
border-bottom: 1px solid var(--color-gray);
padding: 1rem 1rem 0rem 1rem;
font-size: clamp(1rem, 10vw, 2rem);
}
.table-container {
width: 100%;
overflow-x: auto;
display: flex;
justify-content: center;
}
.settings-table {
--max-width: 55rem;
width: 100%;
max-width: var(--max-width);
min-width: 20rem;
border-spacing: 0px;
border: none;
}
.settings-table td {
--middle-spacing: .75rem;
padding-bottom: 1rem;
vertical-align: top;
}
.settings-table td:first-child {
width: 50%;
padding-right: var(--middle-spacing);
text-align: right;
}
.settings-table td:nth-child(2) {
min-width: calc(var(--max-width) * 0.5);
padding-left: var(--middle-spacing);
}
.settings-table td p {
color: var(--color-light-gray);
font-size: .9rem;
}
.number-input {
width: fit-content;
display: flex;
align-items: center;
border: 2px solid var(--color-gray);
border-radius: 4px;
box-shadow: var(--default-shadow);
}
.number-input > input {
width: auto;
border: none;
box-shadow: none;
text-align: right;
}
.number-input > p {
padding: .75rem .75rem .75rem 0rem;
}
.settings-table select {
width: auto;
}
.add-user-container,
.database-container {
margin-bottom: 1rem;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#add-user-button,
#download-db-button {
margin-top: 2rem;
width: min(15rem, 100%);
height: 2rem;
padding: .5rem 1rem;
border-radius: 4px;
background-color: var(--color-gray);
box-shadow: var(--default-shadow);
}
#download-db-button {
height: unset;
}
#add-user-button > svg {
aspect-ratio: 1/1;
height: 1rem;
width: min-content;
}
#user-table {
min-width: 25rem;
border-spacing: 0px;
margin-bottom: 2rem;
}
#user-table th,
#user-table td {
padding: .25rem .5rem;
text-align: left;
}
#user-table tr td {
border-top: 1px solid var(--color-gray);
}
#user-table th:first-child,
#user-table td:first-child {
padding-left: 2rem;
}
#user-table th:last-child,
#user-table td:last-child {
display: flex;
gap: 1rem;
padding-right: 2rem;
}
#user-table td:first-child {
width: 100%;
}
#user-table svg {
aspect-ratio: 1/1;
width: 1.25rem;
height: 1.25rem;
}
#new-username-input,
#new-password-input {
width: 40%;
padding: .25rem;
}
#user-list form {
margin-top: .5rem;
width: 75%;
}
#user-list input[type="password"] {
padding: .25rem;
}
@media (max-width: 40rem) {
#settings-form,
.table-container {
justify-content: flex-start;
}
h2 {
text-align: center;
padding-inline: 0;
}
.settings-table tbody {
display: flex;
flex-direction: column;
}
.settings-table tr {
display: inline-flex;
flex-direction: column;
}
.settings-table td {
width: 100%;
}
.settings-table td:first-child {
text-align: left;
}
.settings-table td:nth-child(2) {
min-width: 0;
}
#user-table {
width: 100%;
}
}

View File

@@ -8,6 +8,7 @@
:root {
--color-light: #ffffff;
--color-light-gray: #6b6b6b;
--color-gray: #3c3c3c;
--color-dark: #1b1b1b;

164
frontend/static/js/admin.js Normal file
View File

@@ -0,0 +1,164 @@
const setting_inputs = {
'allow_new_accounts': document.querySelector('#allow-new-accounts-input'),
'login_time': document.querySelector('#login-time-input'),
'login_time_reset': document.querySelector('#login-time-reset-input')
};
function checkLogin() {
fetch(`${url_prefix}/api/auth/status?api_key=${api_key}`)
.then(response => {
if (!response.ok) return Promise.reject(response.status)
return response.json();
})
.then(json => {
if (!json.result.admin)
window.location.href = `${url_prefix}/reminders`;
})
.catch(e => {
if (e === 401)
window.location.href = `${url_prefix}/`;
else
console.log(e);
});
};
function loadSettings() {
fetch(`${url_prefix}/api/settings`)
.then(response => response.json())
.then(json => {
setting_inputs.allow_new_accounts.checked = json.result.allow_new_accounts;
setting_inputs.login_time.value = Math.round(json.result.login_time / 60);
setting_inputs.login_time_reset.value = json.result.login_time_reset.toString();
});
};
function submitSettings() {
const data = {
'allow_new_accounts': setting_inputs.allow_new_accounts.checked,
'login_time': setting_inputs.login_time.value * 60,
'login_time_reset': setting_inputs.login_time_reset.value === 'true'
};
fetch(`${url_prefix}/api/admin/settings?api_key=${api_key}`, {
'method': 'PUT',
'headers': {'Content-Type': 'application/json'},
'body': JSON.stringify(data)
})
.then(response => response.json())
.then(json => {
if (json.error !== null)
return Promise.reject(json)
})
.catch(json => {
if (['ApiKeyInvalid', 'ApiKeyExpired'].includes(json.error))
window.location.href = `${url_prefix}/`;
});
};
function toggleAddUser() {
const el = document.querySelector('#add-user-row');
if (el.classList.contains('hidden')) {
// Show row
document.querySelector('#new-username-input').value = '';
document.querySelector('#new-password-input').value = '';
el.classList.remove('hidden');
} else {
// Hide row
el.classList.add('hidden');
};
};
function addUser() {
const data = {
'username': document.querySelector('#new-username-input').value,
'password': document.querySelector('#new-password-input').value
};
fetch(`${url_prefix}/api/admin/users?api_key=${api_key}`, {
'method': 'POST',
'headers': {'Content-Type': 'application/json'},
'body': JSON.stringify(data)
})
.then(response => response.json())
.then(json => {
if (json.error !== null)
return Promise.reject(json.error);
toggleAddUser();
loadUsers();
})
.catch(e => {
console.log(e);
});
};
function editUser(id) {
const new_password = document.querySelector(`#user-table tr[data-id="${id}"] input`).value;
fetch(`${url_prefix}/api/admin/users/${id}?api_key=${api_key}`, {
'method': 'PUT',
'headers': {'Content-Type': 'application/json'},
'body': JSON.stringify({'new_password': new_password})
})
.then(response => loadUsers());
};
function deleteUser(id) {
document.querySelector(`#user-table tr[data-id="${id}"]`).remove();
fetch(`${url_prefix}/api/admin/users/${id}?api_key=${api_key}`, {
'method': 'DELETE'
})
};
function loadUsers() {
const table = document.querySelector('#user-list');
table.innerHTML = '';
fetch(`${url_prefix}/api/admin/users?api_key=${api_key}`)
.then(response => response.json())
.then(json => {
json.result.forEach(user => {
const entry = document.createElement('tr');
entry.dataset.id = user.id;
const username = document.createElement('td');
const username_text = document.createElement('p');
username_text.innerText = user.username;
username.appendChild(username_text);
const new_password_form = document.createElement('form');
new_password_form.classList.add('hidden');
new_password_form.action = `javascript:editUser(${user.id})`;
const new_password = document.createElement('input');
new_password.type = 'password';
new_password.placeholder = 'New password';
new_password_form.appendChild(new_password);
username.appendChild(new_password_form);
entry.appendChild(username);
const actions = document.createElement('td');
entry.appendChild(actions);
const edit_user = document.createElement('button');
edit_user.onclick = e => e.currentTarget.parentNode.previousSibling.querySelector('form').classList.toggle('hidden');
edit_user.innerHTML = icons.edit;
actions.appendChild(edit_user);
if (user.username !== 'admin') {
const delete_user = document.createElement('button');
delete_user.onclick = e => deleteUser(user.id);
delete_user.innerHTML = icons.delete;
actions.appendChild(delete_user);
}
table.appendChild(entry);
});
});
};
// code run on load
checkLogin();
loadSettings();
loadUsers();
document.querySelector('#logout-button').onclick = (e) => logout();
document.querySelector('#settings-form').action = 'javascript:submitSettings();';
document.querySelector('#add-user-button').onclick = e => toggleAddUser();
document.querySelector('#add-user-form').action = 'javascript:addUser()';
document.querySelector('#download-db-button').onclick = e =>
window.location.href = `${url_prefix}/api/admin/database?api_key=${api_key}`

View File

@@ -116,10 +116,3 @@ const api_key = getLocalStorage('api_key')['api_key'];
if (api_key === null) {
window.location.href = `${url_prefix}/`;
};
document.getElementById('toggle-nav').addEventListener('click', e => toggleNav());
document.getElementById('home-button').addEventListener('click', e => hideWindow());
document.getElementById('notification-services-button').addEventListener('click', e => showWindow('notification'));
document.getElementById('settings-button').addEventListener('click', e => showWindow('settings'));
document.getElementById('logout-button').addEventListener('click', e => logout());

View File

@@ -231,6 +231,13 @@ document.querySelectorAll('.tab-selector > button').forEach(b => {
b.addEventListener('click', e => showTab(b));
});
document.getElementById('toggle-nav').addEventListener('click', e => toggleNav());
document.getElementById('home-button').addEventListener('click', e => hideWindow());
document.getElementById('notification-services-button').addEventListener('click', e => showWindow('notification'));
document.getElementById('settings-button').addEventListener('click', e => showWindow('settings'));
document.getElementById('logout-button').addEventListener('click', e => logout());
fillSortOptions();
fillReminders();
fillStaticReminders(assume_sorting=true);

View File

@@ -47,7 +47,10 @@ function login(data=null) {
const new_stor = JSON.parse(localStorage.getItem('MIND'));
new_stor.api_key = json.result.api_key;
localStorage.setItem('MIND', JSON.stringify(new_stor));
window.location.href = `${url_prefix}/reminders`;
if (json.result.admin)
window.location.href = `${url_prefix}/admin`;
else
window.location.href = `${url_prefix}/reminders`;
})
.catch(e => {
if (e === 401) {
@@ -98,7 +101,13 @@ function checkLogin() {
fetch(`${url_prefix}/api/auth/status?api_key=${JSON.parse(localStorage.getItem('MIND')).api_key}`)
.then(response => {
if (!response.ok) return Promise.reject(response.status);
window.location.href = '/reminders';
return response.json();
})
.then(json => {
if (json.result.admin)
window.location.href = `${url_prefix}/admin`;
else
window.location.href = `${url_prefix}/reminders`;
})
.catch(e => {
if (e === 401)
@@ -108,14 +117,24 @@ function checkLogin() {
});
};
function checkAllowNewAccounts() {
fetch(`${url_prefix}/api/settings`)
.then(response => response.json())
.then(json => {
if (!json.result.allow_new_accounts)
document.querySelector('.switch-button').classList.add('hidden');
});
};
// code run on load
if (localStorage.getItem('MIND') === null)
localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB'}))
localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB', 'default_service': null}))
const url_prefix = document.getElementById('url_prefix').dataset.value;
checkLogin();
checkAllowNewAccounts();
document.getElementById('login-form').setAttribute('action', 'javascript:login();');
document.getElementById('create-form').setAttribute('action', 'javascript:create();');

View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta id="url_prefix" data-value="{{url_prefix}}">
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.svg') }}" type="image/x-icon">
<link rel="stylesheet" href="{{ url_for('static', filename='css/general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
<script src="{{ url_for('static', filename='js/general.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/admin.js') }}" defer></script>
<title>Admin - MIND</title>
</head>
<body>
<header>
<img src="{{ url_for('static', filename='img/favicon.svg') }}" alt="">
</header>
<main>
<section class="action-buttons">
<button id="save-button" title="Save Settings" type="submit" form="settings-form">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512">
<path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"/>
<path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"/>
</svg>
</button>
<button id="logout-button" aria-label="Log out of MIND" title="Logout">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M11.476,15a1,1,0,0,0-1,1v3a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2H7.476a3,3,0,0,1,3,3V8a1,1,0,0,0,2,0V5a5.006,5.006,0,0,0-5-5H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H7.476a5.006,5.006,0,0,0,5-5V16A1,1,0,0,0,11.476,15Z"></path>
<path d="M22.867,9.879,18.281,5.293a1,1,0,1,0-1.414,1.414l4.262,4.263L6,11a1,1,0,0,0,0,2H6l15.188-.031-4.323,4.324a1,1,0,1,0,1.414,1.414l4.586-4.586A3,3,0,0,0,22.867,9.879Z"></path>
</g>
</svg>
</button>
</section>
<div class="form-container">
<form id="settings-form">
<h2>Authentication</h2>
<div class="table-container">
<table class="settings-table">
<tbody>
<tr>
<td><label for="allow-new-accounts-input">Allow New Accounts</label></td>
<td>
<input type="checkbox" id="allow-new-accounts-input">
<p>Allow users to register a new account. The admin can always add a new account from this panel.</p>
</td>
</tr>
<tr>
<td><label for="login-time-input">Login Time</label></td>
<td>
<div class="number-input">
<input type="number" id="login-time-input" min="1" max="43200">
<p>Min</p>
</div>
<p>For how long users stay logged in before having to authenticate again. Between 1 minute and 1 month.</p>
<p>New values are applied for newly authenticated users.</p>
</td>
</tr>
<tr>
<td><label for="login-time-reset-input">Login Time Trigger</label></td>
<td>
<select id="login-time-reset-input">
<option value="true">After Last Use</option>
<option value="false">After Login</option>
</select>
<p>For when the login timer should start: starting from the last time MIND was used or from when the user has authenticated.</p>
</td>
</tr>
</tbody>
</table>
</div>
</form>
<h2>User Management</h2>
<div class="add-user-container">
<button id="add-user-button">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<g>
<path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192 c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"></path>
</g>
</g>
</svg>
</button>
</div>
<div class="table-container">
<table id="user-table">
<thead>
<th>User</th>
<th>Actions</th>
</thead>
<tbody id="add-user-row" class="hidden">
<form id="add-user-form">
<tr>
<td>
<input type="text" id="new-username-input" placeholder="Username">
<input type="password" id="new-password-input" placeholder="Password">
</td>
<td>
<button type="submit">Add</button>
</td>
</tr>
</form>
</tbody>
<tbody id="user-list">
</tbody>
</table>
</div>
<h2>Database</h2>
<div class="database-container">
<button id="download-db-button">Download Database</button>
</div>
</div>
</main>
</body>
</html>

View File

@@ -14,3 +14,7 @@ def ui_login():
@ui.route('/reminders', methods=methods)
def ui_reminders():
return render_template('reminders.html', url_prefix=logging.URL_PREFIX)
@ui.route('/admin', methods=methods)
def ui_admin():
return render_template('admin.html', url_prefixx=logging.URL_PREFIX)