mirror of
https://github.com/Casvt/MIND.git
synced 2026-02-19 11:54:46 -05:00
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:
4
MIND.py
4
MIND.py
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
119
backend/settings.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
232
frontend/api.py
232
frontend/api.py
@@ -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
|
||||
|
||||
259
frontend/static/css/admin.css
Normal file
259
frontend/static/css/admin.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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
164
frontend/static/js/admin.js
Normal 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}`
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();');
|
||||
|
||||
119
frontend/templates/admin.html
Normal file
119
frontend/templates/admin.html
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user