diff --git a/MIND.py b/MIND.py index 7e7879a..b2bc0c5 100644 --- a/MIND.py +++ b/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) diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index feee8f8..230f471 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -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} diff --git a/backend/db.py b/backend/db.py index aea8981..a7d113c 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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 diff --git a/backend/settings.py b/backend/settings.py new file mode 100644 index 0000000..5ffc1c3 --- /dev/null +++ b/backend/settings.py @@ -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 diff --git a/backend/users.py b/backend/users.py index 9355bb3..732b466 100644 --- a/backend/users.py +++ b/backend/users.py @@ -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 diff --git a/frontend/api.py b/frontend/api.py index 5f525f1..f9996a5 100644 --- a/frontend/api.py +++ b/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/', + '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 + \ No newline at end of file diff --git a/frontend/static/css/admin.css b/frontend/static/css/admin.css new file mode 100644 index 0000000..41f5e5c --- /dev/null +++ b/frontend/static/css/admin.css @@ -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%; + } +} diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index 5a7d580..a332f21 100644 --- a/frontend/static/css/general.css +++ b/frontend/static/css/general.css @@ -8,6 +8,7 @@ :root { --color-light: #ffffff; + --color-light-gray: #6b6b6b; --color-gray: #3c3c3c; --color-dark: #1b1b1b; diff --git a/frontend/static/js/admin.js b/frontend/static/js/admin.js new file mode 100644 index 0000000..de1d604 --- /dev/null +++ b/frontend/static/js/admin.js @@ -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}` diff --git a/frontend/static/js/general.js b/frontend/static/js/general.js index cc249ee..5258aeb 100644 --- a/frontend/static/js/general.js +++ b/frontend/static/js/general.js @@ -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()); diff --git a/frontend/static/js/library.js b/frontend/static/js/library.js index ac74f85..8f5ac0e 100644 --- a/frontend/static/js/library.js +++ b/frontend/static/js/library.js @@ -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); diff --git a/frontend/static/js/login.js b/frontend/static/js/login.js index 36cd44c..965a158 100644 --- a/frontend/static/js/login.js +++ b/frontend/static/js/login.js @@ -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();'); diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html new file mode 100644 index 0000000..fd00ba5 --- /dev/null +++ b/frontend/templates/admin.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + Admin - MIND + + +
+ +
+
+
+ + +
+
+
+

Authentication

+
+ + + + + + + + + + + + + + + +
+ +

Allow users to register a new account. The admin can always add a new account from this panel.

+
+
+ +

Min

+
+

For how long users stay logged in before having to authenticate again. Between 1 minute and 1 month.

+

New values are applied for newly authenticated users.

+
+ +

For when the login timer should start: starting from the last time MIND was used or from when the user has authenticated.

+
+
+
+

User Management

+
+ +
+
+ + + + + + + + + + + + + + + +
UserActions
+
+

Database

+
+ +
+
+
+ + \ No newline at end of file diff --git a/frontend/ui.py b/frontend/ui.py index 50aa990..44fc476 100644 --- a/frontend/ui.py +++ b/frontend/ui.py @@ -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)