diff --git a/docker/routlin-dash/app/action_accountlogout.py b/docker/routlin-dash/app/action_accountlogout.py index 829f296..2349017 100644 --- a/docker/routlin-dash/app/action_accountlogout.py +++ b/docker/routlin-dash/app/action_accountlogout.py @@ -7,5 +7,9 @@ bp = Blueprint('accountlogout', __name__) @bp.route('/action/accountlogout/logout', methods=['POST']) @auth.require_level('viewer') def logout(): + sid = session.get('session_id', '') + if sid: + import config_utils as _cu + _cu.record_session_logout(sid) session.clear() return redirect('/overview') diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 8e95cbf..3bddca6 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -6,7 +6,8 @@ from flask import session APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) CONFIGS_DIR = '/routlin_location' WWW_DIR = '/www' -ACCOUNTS_FILE = f'{APP_DIR}/authorized_accounts.json' +ACCOUNTS_FILE = f'{CONFIGS_DIR}/.dashboard-accounts' +SESSIONS_DB = f'{CONFIGS_DIR}/.dashboard-sessions' CONFIG_FILE = f'{CONFIGS_DIR}/config.json' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' @@ -609,6 +610,72 @@ def revert_group_chain(group_uuid): return errors, succeeded, failed +# Sessions DB ======================================================= + +def _open_sessions_db(): + import sqlite3 as _sq, time as _t + con = _sq.connect(SESSIONS_DB, timeout=5) + con.execute('PRAGMA journal_mode=WAL') + con.executescript(''' + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + email TEXT NOT NULL, + access_level TEXT NOT NULL DEFAULT '', + logged_in_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL + ); + ''') + con.commit() + return con + +def record_session_login(session_id, email, access_level): + import time as _t + try: + con = _open_sessions_db() + now = int(_t.time()) + con.execute( + 'INSERT OR REPLACE INTO sessions(session_id, email, access_level, logged_in_at, last_seen) VALUES(?,?,?,?,?)', + (session_id, email, access_level, now, now) + ) + con.commit() + con.close() + except Exception: + pass + +def record_session_logout(session_id): + try: + con = _open_sessions_db() + con.execute('DELETE FROM sessions WHERE session_id=?', (session_id,)) + con.commit() + con.close() + except Exception: + pass + +def record_session_activity(session_id): + import time as _t + try: + con = _open_sessions_db() + con.execute('UPDATE sessions SET last_seen=? WHERE session_id=?', (int(_t.time()), session_id)) + con.commit() + con.close() + except Exception: + pass + +def get_active_sessions(): + import time as _t + cutoff = int(_t.time()) - 31 * 86400 + try: + con = _open_sessions_db() + rows = con.execute( + 'SELECT email, access_level, logged_in_at, last_seen FROM sessions WHERE last_seen > ? ORDER BY last_seen DESC', + (cutoff,) + ).fetchall() + con.close() + return rows + except Exception: + return [] + + # Misc ============================================================== def run_apply(): diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 28ce412..ef7b1de 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -77,6 +77,9 @@ def serve_view(page_name): view_req = view_def.get('client_requirement') level = factory.client_level() + sid = session.get('session_id', '') + if sid and level > 0: + config_utils.record_session_activity(sid) if not factory.passes(view_req, level): return redirect('/overview' if level > 0 else '/accountlogin') diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index 313378a..5dec1bd 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -56,4 +56,10 @@ def form_login(): session['apply_changes_immediately'] = False session.permanent = True + import uuid as _uuid + sid = str(_uuid.uuid4()) + session['session_id'] = sid + import config_utils as _cu + _cu.record_session_login(sid, account['email_address'], account.get('access_level', 'viewer')) + return redirect('/overview') diff --git a/docker/routlin-dash/app/pages/accountmanage/action.py b/docker/routlin-dash/app/pages/accountmanage/action.py index 795a395..0f44907 100644 --- a/docker/routlin-dash/app/pages/accountmanage/action.py +++ b/docker/routlin-dash/app/pages/accountmanage/action.py @@ -66,6 +66,40 @@ def accounts_add(): return redirect(f'/{_PAGE}') +@bp.route('/action/accountmanage/accounts_edit', methods=['POST']) +@auth.require_level('manager') +def accounts_edit(): + try: + row_index = int(request.form.get('row_index', '')) + except (ValueError, TypeError): + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + + access_level = request.form.get('access_level', '').strip() + if access_level not in VALID_LEVELS: + flash('Invalid access level.', 'error') + return redirect(f'/{_PAGE}') + + data = _load_accounts() + accounts = data.get('accounts', []) + + if row_index < 0 or row_index >= len(accounts): + flash('Account not found.', 'error') + return redirect(f'/{_PAGE}') + + target = accounts[row_index] + if target.get('email_address', '').lower() == session.get('email_address', '').lower(): + flash('You cannot change your own access level.', 'error') + return redirect(f'/{_PAGE}') + + accounts[row_index]['access_level'] = access_level + data['accounts'] = accounts + _save_accounts(data) + + flash('Account updated.', 'success') + return redirect(f'/{_PAGE}') + + @bp.route('/action/accountmanage/accounts_delete', methods=['POST']) @auth.require_level('manager') def accounts_delete(): diff --git a/docker/routlin-dash/app/pages/accountmanage/content.json b/docker/routlin-dash/app/pages/accountmanage/content.json index cf19928..861fb85 100644 --- a/docker/routlin-dash/app/pages/accountmanage/content.json +++ b/docker/routlin-dash/app/pages/accountmanage/content.json @@ -11,38 +11,48 @@ ] }, { - "type": "table", - "datasource": "config:accounts", - "empty_message": "No accounts configured.", - "columns": [ + "type": "card", + "label": "Active Sessions", + "items": [ { - "label": "Email Address", - "field": "email_address" - }, - { - "label": "Access Level", - "field": "access_level" - }, - { - "label": "Added By", - "field": "account_created_by" - }, - { - "label": "Added", - "field": "account_created_utc" - }, - { - "label": "Status", - "field": "account_status", - "render": "badge_active_inactive" + "type": "raw_html", + "html": "%ACTIVE_SESSIONS_TABLE%" } - ], - "row_actions": [ + ] + }, + { + "type": "card", + "label": "User Accounts", + "items": [ { - "action": "/action/accountmanage/accounts_delete", - "method": "post", - "text": "Remove", - "class": "btn-danger btn-sm" + "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_utc"}, + { + "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" + } + ] } ] }, @@ -84,6 +94,43 @@ ] } ] + }, + { + "type": "card", + "id": "edit-form", + "label": "Edit Account", + "hidden": true, + "items": [ + { + "type": "form", + "action": "/action/accountmanage/accounts_edit", + "method": "post", + "items": [ + {"type": "hidden", "name": "row_index", "value": ""}, + { + "type": "field", + "label": "Email Address", + "name": "email_address_display", + "input_type": "text", + "value": "" + }, + { + "type": "field", + "label": "Access Level", + "name": "access_level", + "input_type": "select", + "options": "%ACCOUNT_LEVEL_OPTIONS%" + }, + { + "type": "button_row", + "items": [ + {"type": "button_primary", "text": "Save Changes"}, + {"type": "button_cancel", "text": "Cancel"} + ] + } + ] + } + ] } ] -} \ No newline at end of file +} diff --git a/docker/routlin-dash/app/pages/accountmanage/view.py b/docker/routlin-dash/app/pages/accountmanage/view.py index cee266b..dfb2d57 100644 --- a/docker/routlin-dash/app/pages/accountmanage/view.py +++ b/docker/routlin-dash/app/pages/accountmanage/view.py @@ -1,8 +1,45 @@ import json +import time +from datetime import datetime, timezone import config_utils import factory +def _fmt_ts(ts): + try: + dt = datetime.fromtimestamp(int(ts), tz=timezone.utc) + return dt.strftime('%Y-%m-%d %H:%M UTC') + except Exception: + return '-' + + +def _active_sessions_table(): + rows = config_utils.get_active_sessions() + no_data = '

No active sessions.

' + if not rows: + return no_data + now = int(time.time()) + trs = '' + for email, access_level, logged_in_at, last_seen in rows: + ago = config_utils.relative_time(int(last_seen), now) + trs += ( + f'' + f'{factory.e(email)}' + f'{factory.e(access_level)}' + f'{_fmt_ts(logged_in_at)}' + f'{factory.e(ago)} ago' + f'' + ) + return ( + '' + '' + '' + '' + '' + '' + trs + '
EmailAccess LevelLogged InLast Seen
' + ) + + def collect_tokens(cfg): tokens = config_utils.collect_layout_tokens(cfg) tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([ @@ -10,6 +47,7 @@ def collect_tokens(cfg): {'value': 'administrator', 'label': 'Administrator (can modify configuration)'}, {'value': 'manager', 'label': 'Manager (full access including account management)'}, ]) + tokens['ACTIVE_SESSIONS_TABLE'] = _active_sessions_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', '')