diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 8fb2fcf..d4d127f 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -9,7 +9,7 @@ APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) CONFIGS_DIR = _settings.routlin_location() DATA_DIR = '/data' WWW_DIR = '/www' -ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts' +ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-singleaccount' if _settings.is_single_user() else f'{DATA_DIR}/.dashboard-accounts' CONFIG_FILE = f'{CONFIGS_DIR}/config.json' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' @@ -81,6 +81,23 @@ def init_accounts_db(): pass con.close() +def init_single_user_session_db(): + con = _sqlite3.connect(ACCOUNTS_DB, timeout=5) + con.execute('PRAGMA journal_mode=WAL') + con.executescript(''' + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + logged_in INTEGER NOT NULL DEFAULT 0, + timezone TEXT NOT NULL DEFAULT '', + preferences_json TEXT NOT NULL DEFAULT '{}', + flashes_json TEXT, + session_started_ts INTEGER NOT NULL, + last_seen_ts INTEGER NOT NULL + ); + ''') + con.commit() + con.close() + _LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} _LEVEL_STR_TO_INT = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3} diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 0cddae0..4bb6e32 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -32,8 +32,13 @@ from api_apply_health import bp as api_apply_health_bp from session_interface import SqliteSessionInterface app = Flask(__name__) -app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) -config_utils.init_accounts_db() +if settings.is_single_user(): + from session_interface import SingleUserSessionInterface + config_utils.init_single_user_session_db() + app.session_interface = SingleUserSessionInterface(config_utils.ACCOUNTS_DB) +else: + app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) + config_utils.init_accounts_db() # Static www/ serving ================================================= @@ -83,6 +88,9 @@ def serve_view(page_name): if not factory.passes(view_req, level): return redirect('/overview' if level > 0 else '/accountlogin') + if settings.is_single_user() and page_name in ('accountmanage', 'accountcreate', 'accountverifyemail'): + return redirect('/overview' if level > 0 else '/accountlogin') + cfg = config_utils.load_config() if level >= factory.LEVEL_RANK['administrator']: @@ -138,10 +146,11 @@ app.register_blueprint(physicalinterfaces_bp) app.register_blueprint(portforwarding_bp) app.register_blueprint(portwrangling_bp) app.register_blueprint(preferences_bp) -app.register_blueprint(accountverifyemail_bp) +if not settings.is_single_user(): + app.register_blueprint(accountverifyemail_bp) + app.register_blueprint(accountcreate_bp) + app.register_blueprint(accountmanage_bp) app.register_blueprint(vpn_bp) -app.register_blueprint(accountcreate_bp) -app.register_blueprint(accountmanage_bp) app.register_blueprint(accountlogout_bp) app.register_blueprint(mdns_bp) app.register_blueprint(radius_bp) @@ -151,6 +160,8 @@ app.register_blueprint(api_apply_health_bp) def _seed_initial_account(): + if settings.is_single_user(): + return import uuid as _uuid, time as _t email = settings.get_initial_manager_email() if not email: diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index b402b9d..f79ea25 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -24,6 +24,21 @@ def form_login(): flash('Email address and password are required.', 'error') return redirect(f'/{_PAGE}') + if settings.is_single_user(): + stored_hash = settings.get_initial_manager_password_hash() + if email != settings.get_initial_manager_email() or not stored_hash: + flash('Invalid email address or password.', 'error') + return redirect(f'/{_PAGE}') + if not bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')): + flash('Invalid email address or password.', 'error') + return redirect(f'/{_PAGE}') + session.clear() + session['logged_in'] = True + session['timezone'] = settings.get_host_timezone() + session['apply_changes_immediately'] = False + session.permanent = True + return redirect('/overview') + account = config_utils.get_account_by_email(email) if account is None: diff --git a/docker/routlin-dash/app/pages/preferences/action.py b/docker/routlin-dash/app/pages/preferences/action.py index 9a334ea..f204a17 100644 --- a/docker/routlin-dash/app/pages/preferences/action.py +++ b/docker/routlin-dash/app/pages/preferences/action.py @@ -4,6 +4,7 @@ import bcrypt import auth import config_utils import sanitize +import settings _PAGE = Path(__file__).parent.name @@ -25,9 +26,62 @@ def accountdetails_save(): return redirect(f'/{_PAGE}') +@bp.route('/action/preferences/email_change_direct', methods=['POST']) +@auth.require_level('viewer') +def email_change_direct(): + if session.get('email_address', '').lower() != settings.get_initial_manager_email(): + flash('Not authorised.', 'error') + return redirect(f'/{_PAGE}') + + new_email = sanitize.email(request.form.get('new_email', '').strip()) + if not new_email: + flash('A valid email address is required.', 'error') + return redirect(f'/{_PAGE}') + + current_email = session.get('email_address', '').lower() + if new_email == current_email: + flash('That is already your current email address.', 'error') + return redirect(f'/{_PAGE}') + + if not settings.is_single_user() and config_utils.get_account_by_email(new_email): + flash('That email address is already in use.', 'error') + return redirect(f'/{_PAGE}') + + if not settings.is_single_user(): + try: + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET email=?, requested_email=NULL WHERE account_id=?', + (new_email, session.get('account_id', '')) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not update account: {exc}', 'error') + return redirect(f'/{_PAGE}') + + try: + import json as _json + cfg_path = Path(settings._APP_CONFIG_PATH) + cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {} + cfg['initial_manager_email'] = new_email + cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n') + settings._app_config_cache = None + except Exception as exc: + flash(f'Could not update app_config.json: {exc}', 'error') + return redirect(f'/{_PAGE}') + + session['email_address'] = new_email + flash('Email address updated.', 'success') + return redirect(f'/{_PAGE}') + + @bp.route('/action/preferences/email_change_request', methods=['POST']) @auth.require_level('viewer') def email_change_request(): + if settings.is_single_user(): + flash('Not available in single-user mode.', 'error') + return redirect(f'/{_PAGE}') new_email = sanitize.email(request.form.get('new_email', '').strip()) if not new_email: flash('A valid email address is required.', 'error') @@ -77,6 +131,26 @@ def changepassword_save(): flash('New password must be at least 8 characters.', 'error') return redirect(f'/{_PAGE}') + hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + if settings.is_single_user(): + stored_hash = settings.get_initial_manager_password_hash() + if not stored_hash or not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash.encode('utf-8')): + flash('Current password is incorrect.', 'error') + return redirect(f'/{_PAGE}') + try: + import json as _json + cfg_path = Path(settings._APP_CONFIG_PATH) + cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {} + cfg['initial_manager_password'] = hashed + cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n') + settings._app_config_cache = None + except Exception as exc: + flash(f'Could not update password: {exc}', 'error') + return redirect(f'/{_PAGE}') + flash('Password changed successfully.', 'success') + return redirect(f'/{_PAGE}') + account = config_utils.get_account_by_id(session.get('account_id', '')) if account is None: flash('Account not found. Please log in again.', 'error') @@ -86,8 +160,6 @@ def changepassword_save(): flash('Current password is incorrect.', 'error') return redirect(f'/{_PAGE}') - hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - try: con = config_utils.open_accounts_db() con.execute( diff --git a/docker/routlin-dash/app/pages/preferences/content.json b/docker/routlin-dash/app/pages/preferences/content.json index 2a374a1..d7977c5 100644 --- a/docker/routlin-dash/app/pages/preferences/content.json +++ b/docker/routlin-dash/app/pages/preferences/content.json @@ -54,7 +54,7 @@ }, { "type": "form", - "action": "/action/preferences/email_change_request", + "action": "%EMAIL_CHANGE_ACTION%", "method": "post", "items": [ { @@ -70,7 +70,7 @@ "items": [ { "type": "button_primary", - "text": "Submit Request" + "text": "%EMAIL_CHANGE_BTN_TEXT%" } ] } diff --git a/docker/routlin-dash/app/pages/preferences/view.py b/docker/routlin-dash/app/pages/preferences/view.py index b4e8812..3d2e68d 100644 --- a/docker/routlin-dash/app/pages/preferences/view.py +++ b/docker/routlin-dash/app/pages/preferences/view.py @@ -3,6 +3,7 @@ from flask import session import sanitize import config_utils import factory +import settings def collect_tokens(cfg): @@ -12,8 +13,19 @@ def collect_tokens(cfg): tokens['PREF_TIMEZONE'] = session.get('timezone', '') tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) - account = config_utils.get_account_by_id(session.get('account_id', '')) - requested = (account or {}).get('requested_email', '') + is_initial_manager = session.get('email_address', '').lower() == settings.get_initial_manager_email() + if is_initial_manager: + tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_direct' + tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Change Email' + else: + tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_request' + tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Submit Request' + + if not settings.is_single_user(): + account = config_utils.get_account_by_id(session.get('account_id', '')) + requested = (account or {}).get('requested_email', '') + else: + requested = '' if requested: tokens['PENDING_EMAIL_BAR'] = ( f'