diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 76304e6..a20cd04 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -43,6 +43,7 @@ def init_accounts_db(): email TEXT NOT NULL UNIQUE, access_level INTEGER NOT NULL, hashed_password TEXT, + requested_email TEXT, created_ts INTEGER NOT NULL, created_by TEXT NOT NULL ); @@ -68,6 +69,11 @@ def init_accounts_db(): ); ''') con.commit() + try: + con.execute('ALTER TABLE accounts ADD COLUMN requested_email TEXT') + con.commit() + except Exception: + pass con.close() _LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} @@ -83,6 +89,7 @@ def _account_row_to_dict(row): d['access_level_int'] = d.get('access_level', 1) d['access_level'] = _LEVEL_INT_TO_STR.get(d['access_level_int'], 'viewer') d['account_status'] = 'active' if d.get('hashed_password') else 'pending' + d['requested_email'] = d.get('requested_email') or '' d['account_created_by'] = d.get('created_by', '') ts = d.get('created_ts', 0) try: diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index ac4ddeb..8955b5f 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -31,6 +31,33 @@ def form_login(): return redirect(f'/{_PAGE}') if not account.get('hashed_password'): + # Either brand-new account or email change approved - check for a pending verification row + try: + con = config_utils.open_accounts_db() + client = con.execute( + 'SELECT * FROM clients WHERE email=? AND verification_code IS NOT NULL', (email,) + ).fetchone() + if client and client['cookie_unique_token'] != session.sid: + con.execute( + '''INSERT INTO clients (cookie_unique_token, email, hashed_password, verification_code, code_sent_ts) + VALUES (?,?,?,?,?) + ON CONFLICT(cookie_unique_token) DO UPDATE SET + email=excluded.email, + hashed_password=excluded.hashed_password, + verification_code=excluded.verification_code, + code_sent_ts=excluded.code_sent_ts''', + (session.sid, client['email'], client['hashed_password'], + client['verification_code'], client['code_sent_ts']) + ) + con.execute('DELETE FROM clients WHERE cookie_unique_token=?', + (client['cookie_unique_token'],)) + con.commit() + con.close() + except Exception: + client = None + if client: + flash('Please check your inbox and enter your verification code.', 'info') + return redirect('/accountverifyemail') flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/accountmanage/action.py b/docker/routlin-dash/app/pages/accountmanage/action.py index 47dbd3c..7a783e2 100644 --- a/docker/routlin-dash/app/pages/accountmanage/action.py +++ b/docker/routlin-dash/app/pages/accountmanage/action.py @@ -1,6 +1,6 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import os, re, sqlite3 +import os, re, secrets, sqlite3, time from datetime import datetime, timezone import auth import config_utils @@ -13,6 +13,93 @@ bp = Blueprint(_PAGE, __name__) VALID_LEVELS = {'viewer': 1, 'administrator': 2, 'manager': 3} +@bp.route('/action/accountmanage/email_change_deny', methods=['POST']) +@auth.require_level('manager') +def email_change_deny(): + account_id = request.form.get('account_id', '').strip() + if not account_id: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + try: + con = config_utils.open_accounts_db() + con.execute('UPDATE accounts SET requested_email=NULL WHERE account_id=?', (account_id,)) + con.commit() + con.close() + flash('Email change request denied.', 'success') + except Exception as exc: + flash(f'Could not deny request: {exc}', 'error') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/accountmanage/email_change_approve', methods=['POST']) +@auth.require_level('manager') +def email_change_approve(): + from pages.accountcreate.action import _send_verification_email, CODE_TTL_SECS + + account_id = request.form.get('account_id', '').strip() + if not account_id: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + + try: + con = config_utils.open_accounts_db() + row = con.execute( + 'SELECT * FROM accounts WHERE account_id=?', (account_id,) + ).fetchone() + con.close() + except Exception: + row = None + + if not row or not row['requested_email']: + flash('No pending email change found.', 'error') + return redirect(f'/{_PAGE}') + + new_email = row['requested_email'] + old_password = row['hashed_password'] + code = f'{secrets.randbelow(1000000):06d}' + code_sent_ts = int(time.time()) + + try: + _send_verification_email(new_email, code) + except Exception as exc: + flash(f'Could not send verification email: {exc}', 'error') + return redirect(f'/{_PAGE}') + + try: + con = config_utils.open_accounts_db() + last_cookie = con.execute( + 'SELECT session_id FROM sessions WHERE account_id=? ORDER BY last_seen_ts DESC LIMIT 1', + (account_id,) + ).fetchone() + cookie = last_cookie['session_id'] if last_cookie else None + + if cookie: + con.execute( + '''INSERT INTO clients (cookie_unique_token, email, hashed_password, verification_code, code_sent_ts) + VALUES (?,?,?,?,?) + ON CONFLICT(cookie_unique_token) DO UPDATE SET + email=excluded.email, + hashed_password=excluded.hashed_password, + verification_code=excluded.verification_code, + code_sent_ts=excluded.code_sent_ts''', + (cookie, new_email, old_password, code, code_sent_ts) + ) + + con.execute( + 'UPDATE accounts SET email=?, hashed_password=NULL, requested_email=NULL WHERE account_id=?', + (new_email, account_id) + ) + con.execute('DELETE FROM sessions WHERE account_id=?', (account_id,)) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not apply email change: {exc}', 'error') + return redirect(f'/{_PAGE}') + + flash(f'Email changed to {new_email}. A verification email has been sent.', 'success') + return redirect(f'/{_PAGE}') + + @bp.route('/action/accountmanage/session_invalidate', methods=['POST']) @auth.require_level('manager') def session_invalidate(): diff --git a/docker/routlin-dash/app/pages/accountmanage/content.json b/docker/routlin-dash/app/pages/accountmanage/content.json index 7f9b252..735d6c4 100644 --- a/docker/routlin-dash/app/pages/accountmanage/content.json +++ b/docker/routlin-dash/app/pages/accountmanage/content.json @@ -25,34 +25,8 @@ "label": "User Accounts", "items": [ { - "type": "table", - "datasource": "config:accounts", - "empty_message": "No accounts configured.", - "columns": [ - {"label": "Email Address", "field": "email_address"}, - {"label": "Access Level", "field": "access_level"}, - {"label": "Added By", "field": "account_created_by"}, - {"label": "Added", "field": "account_created_ts"}, - { - "label": "Status", - "field": "account_status", - "render": "badge_active_inactive" - } - ], - "row_actions": [ - { - "method": "js_edit", - "target": "edit-form", - "text": "Edit", - "class": "btn-ghost btn-sm" - }, - { - "action": "/action/accountmanage/accounts_delete", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] + "type": "raw_html", + "html": "%ACCOUNTS_TABLE%" } ] }, diff --git a/docker/routlin-dash/app/pages/accountmanage/view.py b/docker/routlin-dash/app/pages/accountmanage/view.py index 7b07460..08211a0 100644 --- a/docker/routlin-dash/app/pages/accountmanage/view.py +++ b/docker/routlin-dash/app/pages/accountmanage/view.py @@ -81,6 +81,76 @@ def _active_sessions_table(): ) +def _accounts_table(): + accounts = config_utils.list_accounts() + if not accounts: + return '

No accounts.

' + + trs = '' + for i, acct in enumerate(accounts): + email = acct.get('email_address', '') + req_email = acct.get('requested_email', '') + acct_id = acct.get('account_id', '') + level = acct.get('access_level', 'viewer') + status = acct.get('account_status', 'pending') + + if req_email: + approve_btn = ( + f'
' + f'' + f'' + f'
' + ) + deny_btn = ( + f'
' + f'' + f'' + f'
' + ) + email_cell = ( + f'{factory.e(email)}
' + f'' + f'{approve_btn} or {deny_btn} change to: {factory.e(req_email)}' + f'' + ) + else: + email_cell = factory.e(email) + + row_json = factory.e(json.dumps({'email_address': email, 'access_level': level})) + edit_btn = ( + f'' + ) + delete_btn = ( + f'
' + f'' + f'' + f'
' + ) + + trs += ( + f'' + f'{email_cell}' + f'{factory.e(level)}' + f'{factory.e(status)}' + f'{edit_btn} {delete_btn}' + f'' + ) + + return ( + '' + '' + '' + '' + '' + '' + trs + '
EmailAccess LevelStatus
' + ) + + def collect_tokens(cfg): tokens = config_utils.collect_layout_tokens(cfg) tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([ @@ -89,6 +159,7 @@ def collect_tokens(cfg): {'value': 'manager', 'label': 'Manager (full access including account management)'}, ]) tokens['ACTIVE_SESSIONS_TABLE'] = _active_sessions_table() + tokens['ACCOUNTS_TABLE'] = _accounts_table() content = factory.load_json(f'{factory.PAGES_DIR}/accountmanage/content.json') for table_item in factory.iter_table_items(content.get('items', [])): ds = table_item.get('datasource', '') diff --git a/docker/routlin-dash/app/pages/preferences/action.py b/docker/routlin-dash/app/pages/preferences/action.py index 491ead4..b680bce 100644 --- a/docker/routlin-dash/app/pages/preferences/action.py +++ b/docker/routlin-dash/app/pages/preferences/action.py @@ -37,6 +37,39 @@ def accountdetails_save(): return redirect(f'/{_PAGE}') +@bp.route('/action/preferences/email_change_request', methods=['POST']) +@auth.require_level('viewer') +def email_change_request(): + 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 config_utils.get_account_by_email(new_email): + flash('That email address is already in use.', 'error') + return redirect(f'/{_PAGE}') + + try: + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET requested_email=? WHERE account_id=?', + (new_email, session.get('account_id', '')) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not submit request: {exc}', 'error') + return redirect(f'/{_PAGE}') + + flash('Email change request submitted. A manager will review it.', 'success') + return redirect(f'/{_PAGE}') + + @bp.route('/action/preferences/changepassword_save', methods=['POST']) @auth.require_level('viewer') def changepassword_save(): diff --git a/docker/routlin-dash/app/pages/preferences/content.json b/docker/routlin-dash/app/pages/preferences/content.json index a8aab94..2a374a1 100644 --- a/docker/routlin-dash/app/pages/preferences/content.json +++ b/docker/routlin-dash/app/pages/preferences/content.json @@ -23,31 +23,19 @@ "action": "/action/preferences/accountdetails_save", "method": "post", "items": [ - { - "type": "field", - "label": "Email Address", - "name": "email", - "input_type": "text", - "value": "%PREF_EMAIL%", - "readonly": true, - "hint": "Contact your manager to change your email address." - }, { "type": "field", "label": "Timezone", "name": "timezone", "input_type": "select", "value": "%PREF_TIMEZONE%", - "options": "%TIMEZONE_OPTIONS%", - "hint": "All timestamps will be displayed in this timezone." + "options": "%TIMEZONE_OPTIONS%" }, { "type": "button_row", "items": [ { "type": "button_primary", - "action": "/action/preferences/accountdetails_save", - "method": "post", "text": "Save Preferences" } ] @@ -56,6 +44,40 @@ } ] }, + { + "type": "card", + "label": "Change Email", + "items": [ + { + "type": "raw_html", + "html": "%PENDING_EMAIL_BAR%" + }, + { + "type": "form", + "action": "/action/preferences/email_change_request", + "method": "post", + "items": [ + { + "type": "field", + "label": "Email Address", + "name": "new_email", + "input_type": "text", + "value": "%PREF_EMAIL%", + "placeholder": "New email address" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Submit Request" + } + ] + } + ] + } + ] + }, { "type": "card", "label": "Change Password", @@ -91,8 +113,6 @@ "items": [ { "type": "button_primary", - "action": "/action/preferences/changepassword_save", - "method": "post", "text": "Change Password" } ] @@ -102,4 +122,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docker/routlin-dash/app/pages/preferences/view.py b/docker/routlin-dash/app/pages/preferences/view.py index d35f4a4..b4e8812 100644 --- a/docker/routlin-dash/app/pages/preferences/view.py +++ b/docker/routlin-dash/app/pages/preferences/view.py @@ -2,12 +2,25 @@ import json from flask import session import sanitize import config_utils +import factory def collect_tokens(cfg): tokens = config_utils.collect_layout_tokens(cfg) blank = [{'value': '', 'label': '-- Select Timezone --'}] - tokens['PREF_EMAIL'] = session.get('email_address', '') - tokens['PREF_TIMEZONE'] = session.get('timezone', '') - tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) + tokens['PREF_EMAIL'] = session.get('email_address', '') + 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', '') + if requested: + tokens['PENDING_EMAIL_BAR'] = ( + f'
' + f'A request to change email to {factory.e(requested)} is pending approval.' + f'
' + ) + else: + tokens['PENDING_EMAIL_BAR'] = '' + return tokens