Development

This commit is contained in:
Matthew Grotke 2026-06-10 14:23:47 -04:00
parent f5722f3c7b
commit d60bf15ce4
15 changed files with 367 additions and 285 deletions

View file

@ -1,7 +1,7 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, os, bcrypt, secrets, smtplib
from datetime import datetime, timezone, timedelta
import os, bcrypt, secrets, smtplib
import time
from email.message import EmailMessage
import auth
import config_utils
@ -11,15 +11,7 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
CODE_TTL_MIN = 15
def _load_accounts():
try:
with open(config_utils.ACCOUNTS_FILE) as f:
return json.load(f)
except Exception:
return {'accounts': []}
CODE_TTL_SECS = 15 * 60
def _send_verification_email(to_address, code):
@ -38,7 +30,7 @@ def _send_verification_email(to_address, code):
msg['To'] = to_address
msg.set_content(
f'Your verification code is: {code}\n\n'
f'This code expires in {CODE_TTL_MIN} minutes.\n\n'
f'This code expires in 15 minutes.\n\n'
f'If you did not request this, you can ignore this email.'
)
@ -51,10 +43,19 @@ def _send_verification_email(to_address, code):
smtp.send_message(msg)
def _tz_to_offset_seconds(tz_str):
try:
from zoneinfo import ZoneInfo
from datetime import datetime
return int(datetime.now(ZoneInfo(tz_str)).utcoffset().total_seconds())
except Exception:
import settings as _s
return _s.get_host_utc_offset()
@bp.route('/action/accountcreate/form_create', methods=['POST'])
@auth.require_level('nothing')
def form_create():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview')
@ -75,8 +76,7 @@ def form_create():
flash('Password must be at least 8 characters.', 'error')
return redirect(f'/{_PAGE}')
accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
account = config_utils.get_account_by_email(email)
if account is None:
flash('Email address not recognised. Contact your manager.', 'error')
@ -86,10 +86,11 @@ def form_create():
flash('This account is already set up. Please log in instead.', 'error')
return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
code = f'{secrets.randbelow(1000000):06d}'
expires_ts = int(time.time()) + CODE_TTL_SECS
tz_offset = _tz_to_offset_seconds(tz)
try:
_send_verification_email(account['email_address'], code)
@ -97,12 +98,20 @@ def form_create():
flash(f'Could not send verification email: {exc}', 'error')
return redirect(f'/{_PAGE}')
session['pending_create_account'] = {
'email': account['email_address'],
'hashed_password': hashed.decode('utf-8'),
'timezone': tz,
'code': code,
'expires': expires,
}
try:
con = config_utils.open_accounts_db()
con.execute(
'''INSERT OR REPLACE INTO pending_verifications
(email, hashed_password, tz_offset_seconds, code, expires_ts)
VALUES (?,?,?,?,?)''',
(account['email_address'].lower(), hashed, tz_offset, code, expires_ts)
)
con.commit()
con.close()
except Exception as exc:
flash(f'Could not store verification: {exc}', 'error')
return redirect(f'/{_PAGE}')
session['pending_verify_email'] = account['email_address']
return redirect('/accountverifyemail')

View file

@ -1,28 +1,19 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, bcrypt
import bcrypt
import auth
import config_utils
import sanitize
import settings
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
def _load_accounts():
try:
with open(config_utils.ACCOUNTS_FILE) as f:
return json.load(f)
except Exception:
return {'accounts': []}
@bp.route('/action/accountlogin/form_login', methods=['POST'])
@auth.require_level('nothing')
def form_login():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview')
@ -33,8 +24,7 @@ def form_login():
flash('Email address and password are required.', 'error')
return redirect(f'/{_PAGE}')
accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
account = config_utils.get_account_by_email(email)
if account is None:
flash('Email address not recognised.', 'error')
@ -44,16 +34,14 @@ def form_login():
flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
return redirect(f'/{_PAGE}')
stored_hash = account['hashed_password'].encode('utf-8')
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
if not bcrypt.checkpw(password.encode('utf-8'), account['hashed_password'].encode('utf-8')):
flash('Invalid email address or password.', 'error')
return redirect(f'/{_PAGE}')
session.clear()
session['email_address'] = account['email_address']
session['access_level'] = account.get('access_level', 'viewer')
session['timezone'] = account.get('timezone', '')
session['account_id'] = account['account_id']
session['tz_offset_seconds'] = settings.get_host_utc_offset()
session['apply_changes_immediately'] = False
session.permanent = True
session.permanent = True
return redirect('/overview')

View file

@ -1,6 +1,6 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, os, re, sqlite3
import os, re, sqlite3
from datetime import datetime, timezone
import auth
import config_utils
@ -10,24 +10,31 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
VALID_LEVELS = {'viewer', 'administrator', 'manager'}
VALID_LEVELS = {'viewer': 1, 'administrator': 2, 'manager': 3}
def _load_accounts():
@bp.route('/action/accountmanage/session_invalidate', methods=['POST'])
@auth.require_level('manager')
def session_invalidate():
sid = request.form.get('session_id', '').strip()
if not sid:
flash('Invalid request.', 'error')
return redirect(f'/{_PAGE}')
try:
with open(config_utils.ACCOUNTS_FILE) as f:
return json.load(f)
con = config_utils.open_accounts_db()
con.execute('DELETE FROM sessions WHERE session_id=?', (sid,))
con.commit()
con.close()
flash('Session invalidated.', 'success')
except Exception:
return {'accounts': []}
def _save_accounts(data):
with open(config_utils.ACCOUNTS_FILE, 'w') as f:
json.dump(data, f, indent=2)
flash('Failed to invalidate session.', 'error')
return redirect(f'/{_PAGE}')
@bp.route('/action/accountmanage/accounts_add', methods=['POST'])
@auth.require_level('manager')
def accounts_add():
import uuid as _uuid, time as _t
email = sanitize.email(request.form.get('email_address', ''))
access_level = request.form.get('access_level', '').strip()
@ -43,26 +50,24 @@ def accounts_add():
flash('Invalid access level.', 'error')
return redirect(f'/{_PAGE}')
data = _load_accounts()
accounts = data.get('accounts', [])
if any(a.get('email_address', '').lower() == email for a in accounts):
if config_utils.get_account_by_email(email):
flash('An account with that email address already exists.', 'error')
return redirect(f'/{_PAGE}')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
accounts.append({
'email_address': email,
'access_level': access_level,
'account_created_utc': now,
'account_created_by': session.get('email_address', ''),
'hashed_password': '',
'timezone': '',
})
data['accounts'] = accounts
_save_accounts(data)
try:
con = config_utils.open_accounts_db()
con.execute(
'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)',
(str(_uuid.uuid4()), email, VALID_LEVELS[access_level], int(_t.time()),
session.get('email_address', ''))
)
con.commit()
con.close()
except Exception as exc:
flash(f'Could not add account: {exc}', 'error')
return redirect(f'/{_PAGE}')
flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success')
flash(f'Authorization added for {email}.', 'success')
return redirect(f'/{_PAGE}')
@ -80,9 +85,7 @@ def accounts_edit():
flash('Invalid access level.', 'error')
return redirect(f'/{_PAGE}')
data = _load_accounts()
accounts = data.get('accounts', [])
accounts = config_utils.list_accounts()
if row_index < 0 or row_index >= len(accounts):
flash('Account not found.', 'error')
return redirect(f'/{_PAGE}')
@ -92,29 +95,19 @@ def accounts_edit():
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/session_invalidate', methods=['POST'])
@auth.require_level('manager')
def session_invalidate():
sid = request.form.get('session_id', '').strip()
if not sid:
flash('Invalid request.', 'error')
return redirect(f'/{_PAGE}')
try:
con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5)
con.execute('DELETE FROM sessions WHERE session_id=?', (sid,))
con = config_utils.open_accounts_db()
con.execute(
'UPDATE accounts SET access_level=? WHERE account_id=?',
(VALID_LEVELS[access_level], target['account_id'])
)
con.commit()
con.close()
flash('Session invalidated.', 'success')
except Exception:
flash('Failed to invalidate session.', 'error')
except Exception as exc:
flash(f'Could not update account: {exc}', 'error')
return redirect(f'/{_PAGE}')
flash('Account updated.', 'success')
return redirect(f'/{_PAGE}')
@ -127,26 +120,29 @@ def accounts_delete():
flash('Invalid request.', 'error')
return redirect(f'/{_PAGE}')
data = _load_accounts()
accounts = data.get('accounts', [])
accounts = config_utils.list_accounts()
if row_index < 0 or row_index >= len(accounts):
flash('Account not found.', 'error')
return redirect(f'/{_PAGE}')
target = accounts[row_index]
target = accounts[row_index]
target_email = target.get('email_address', '').lower()
current_email = session.get('email_address', '').lower()
initial_email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
if target_email == current_email and target_email != initial_email:
flash('You cannot remove your own account.', 'error')
return redirect(f'/{_PAGE}')
removed_email = target.get('email_address', '')
accounts.pop(row_index)
data['accounts'] = accounts
_save_accounts(data)
try:
con = config_utils.open_accounts_db()
con.execute('DELETE FROM sessions WHERE account_id=?', (target['account_id'],))
con.execute('DELETE FROM accounts WHERE account_id=?', (target['account_id'],))
con.commit()
con.close()
except Exception as exc:
flash(f'Could not delete account: {exc}', 'error')
return redirect(f'/{_PAGE}')
flash(f'Account for {removed_email} has been removed.', 'success')
flash(f'Account for {target["email_address"]} has been removed.', 'success')
return redirect(f'/{_PAGE}')

View file

@ -32,7 +32,7 @@
{"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": "Added", "field": "account_created_ts"},
{
"label": "Status",
"field": "account_status",

View file

@ -1,25 +1,33 @@
import json
import sqlite3
import time
from datetime import datetime, timezone
from datetime import datetime
import config_utils
import factory
def _fmt_ts(ts):
def _fmt_ts(ts, now):
try:
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
return dt.strftime('%Y-%m-%d %H:%M UTC')
dt = datetime.fromtimestamp(int(ts))
ago = config_utils.relative_time(int(ts), now)
return f'{dt.strftime("%Y-%m-%d %H:%M")} ({ago} ago)'
except Exception:
return '-'
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'}
def _active_sessions_table():
try:
con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5)
con = sqlite3.connect(config_utils.ACCOUNTS_DB, timeout=5)
con.row_factory = sqlite3.Row
rows = con.execute(
'SELECT session_id, email, access_level, created_at, last_seen'
' FROM sessions ORDER BY last_seen DESC'
'''SELECT s.session_id, a.email, a.access_level,
s.session_started_ts, s.last_seen_ts
FROM sessions s
JOIN accounts a ON a.account_id = s.account_id
ORDER BY s.last_seen_ts DESC'''
).fetchall()
con.close()
except Exception:
@ -30,12 +38,21 @@ def _active_sessions_table():
now = int(time.time())
trs = ''
for sid, email, access_level, created_at, last_seen in rows:
online = (now - int(last_seen)) < 300
badge = (
'<span class="badge badge-enabled">Online</span>'
if online else
'<span class="badge badge-disabled">Offline</span>'
for row in rows:
sid = row['session_id']
email = row['email']
access_level = _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer')
started_ts = row['session_started_ts']
last_seen = row['last_seen_ts']
online = (now - int(last_seen)) < 300
ago = config_utils.relative_time(int(last_seen), now)
tip = factory.e(f'Last seen {ago} ago')
badge_cls = 'badge-enabled' if online else 'badge-disabled'
badge_lbl = 'Online' if online else 'Offline'
badge = (
f'<span class="tooltip-wrap" data-tooltip="{tip}">'
f'<span class="badge {badge_cls}">{badge_lbl}</span>'
f'</span>'
)
btn = (
f'<form method="post" action="/action/accountmanage/session_invalidate"'
@ -49,8 +66,7 @@ def _active_sessions_table():
f'<td class="table-cell">{factory.e(email)}</td>'
f'<td class="table-cell">{factory.e(access_level)}</td>'
f'<td class="table-cell">{badge}</td>'
f'<td class="table-cell">{_fmt_ts(created_at)}</td>'
f'<td class="table-cell">{_fmt_ts(last_seen)}</td>'
f'<td class="table-cell">{_fmt_ts(started_ts, now)}</td>'
f'<td class="table-cell">{btn}</td>'
f'</tr>'
)
@ -60,7 +76,6 @@ def _active_sessions_table():
'<th class="table-header">Access Level</th>'
'<th class="table-header">Status</th>'
'<th class="table-header">Logged In</th>'
'<th class="table-header">Last Seen</th>'
'<th class="table-header"></th>'
'</tr></thead><tbody>' + trs + '</tbody></table>'
)

View file

@ -1,84 +1,86 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, os, secrets
from datetime import datetime, timezone, timedelta
import time, secrets
import auth
import config_utils
import settings
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
def _load_accounts():
try:
with open(config_utils.ACCOUNTS_FILE) as f:
return json.load(f)
except Exception:
return {'accounts': []}
def _save_accounts(data):
with open(config_utils.ACCOUNTS_FILE, 'w') as f:
json.dump(data, f, indent=2)
@bp.route('/action/accountverifyemail/email_verify', methods=['POST'])
@auth.require_level('nothing')
def email_verify():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview')
pending = session.get('pending_create_account')
if not pending:
pending_email = session.get('pending_verify_email', '').lower()
if not pending_email:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate')
expires = datetime.fromisoformat(pending['expires'])
if datetime.now(tz=timezone.utc) > expires:
session.pop('pending_create_account', None)
try:
con = config_utils.open_accounts_db()
row = con.execute(
'SELECT * FROM pending_verifications WHERE email=?', (pending_email,)
).fetchone()
con.close()
except Exception:
row = None
if not row:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate')
if int(time.time()) > row['expires_ts']:
try:
con = config_utils.open_accounts_db()
con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,))
con.commit()
con.close()
except Exception:
pass
session.pop('pending_verify_email', None)
flash('Verification code has expired. Please start over.', 'error')
return redirect('/accountcreate')
submitted = request.form.get('code', '').strip()
if submitted != pending['code']:
if submitted != row['code']:
flash('Incorrect verification code.', 'error')
return redirect(f'/{_PAGE}')
data = _load_accounts()
accounts = data.get('accounts', [])
account = next(
(a for a in accounts if a.get('email_address', '').lower() == pending['email'].lower()),
None
)
account = config_utils.get_account_by_email(pending_email)
if account is None:
session.pop('pending_create_account', None)
session.pop('pending_verify_email', None)
flash('Account no longer exists. Contact your manager.', 'error')
return redirect('/accountcreate')
if account.get('hashed_password'):
session.pop('pending_create_account', None)
session.pop('pending_verify_email', None)
flash('This account is already set up. Please log in.', 'error')
return redirect('/accountlogin')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
account['hashed_password'] = pending['hashed_password']
account['timezone'] = pending['timezone']
if not account.get('account_created_utc'):
account['account_created_utc'] = now
if not account.get('account_created_by'):
account['account_created_by'] = 'self'
now = int(time.time())
try:
con = config_utils.open_accounts_db()
con.execute(
'UPDATE accounts SET hashed_password=?, created_ts=?, created_by=? WHERE account_id=?',
(row['hashed_password'], now, 'self', account['account_id'])
)
con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,))
con.commit()
con.close()
except Exception as exc:
flash(f'Could not complete account setup: {exc}', 'error')
return redirect(f'/{_PAGE}')
_save_accounts(data)
session.pop('pending_create_account', None)
session['email_address'] = account['email_address']
session['access_level'] = account.get('access_level', 'viewer')
session['timezone'] = pending['timezone']
session.permanent = True
session.pop('pending_verify_email', None)
session['account_id'] = account['account_id']
session['tz_offset_seconds'] = int(row['tz_offset_seconds'])
session['apply_changes_immediately'] = False
session.permanent = True
return redirect('/overview')
@ -86,30 +88,35 @@ def email_verify():
@bp.route('/action/accountverifyemail/email_resend')
@auth.require_level('nothing')
def email_resend():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview')
from pages.accountcreate.action import _send_verification_email, CODE_TTL_MIN
from pages.accountcreate.action import _send_verification_email, CODE_TTL_SECS
pending = session.get('pending_create_account')
if not pending:
pending_email = session.get('pending_verify_email', '').lower()
if not pending_email:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate')
code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
code = f'{secrets.randbelow(1000000):06d}'
expires_ts = int(time.time()) + CODE_TTL_SECS
try:
_send_verification_email(pending['email'], code)
_send_verification_email(pending_email, code)
except Exception as exc:
flash(f'Could not resend verification email: {exc}', 'error')
return redirect(f'/{_PAGE}')
pending['code'] = code
pending['expires'] = expires
session['pending_create_account'] = pending
try:
con = config_utils.open_accounts_db()
con.execute(
'UPDATE pending_verifications SET code=?, expires_ts=? WHERE email=?',
(code, expires_ts, pending_email)
)
con.commit()
con.close()
except Exception:
pass
flash('A new verification code has been sent.', 'success')
return redirect(f'/{_PAGE}')

View file

@ -1,6 +1,6 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, bcrypt
import bcrypt
import auth
import config_utils
import sanitize
@ -10,17 +10,14 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
def _load_accounts():
def _tz_to_offset_seconds(tz_str):
try:
with open(config_utils.ACCOUNTS_FILE) as f:
return json.load(f)
from zoneinfo import ZoneInfo
from datetime import datetime
return int(datetime.now(ZoneInfo(tz_str)).utcoffset().total_seconds())
except Exception:
return {'accounts': []}
def _save_accounts(data):
with open(config_utils.ACCOUNTS_FILE, 'w') as f:
json.dump(data, f, indent=2)
import settings as _s
return _s.get_host_utc_offset()
@bp.route('/action/preferences/accountdetails_save', methods=['POST'])
@ -32,19 +29,8 @@ def accountdetails_save():
flash('Timezone is required.', 'error')
return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower()
data = _load_accounts()
accounts = data.get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None:
flash('Account not found. Please log in again.', 'error')
return redirect('/accountlogin')
account['timezone'] = tz
_save_accounts(data)
session['timezone'] = tz
tz_offset = _tz_to_offset_seconds(tz)
session['tz_offset_seconds'] = tz_offset
flash('Preferences saved.', 'success')
return redirect(f'/{_PAGE}')
@ -69,26 +55,28 @@ def changepassword_save():
flash('New password must be at least 8 characters.', 'error')
return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower()
data = _load_accounts()
accounts = data.get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
account = config_utils.get_account_by_id(session.get('account_id', ''))
if account is None:
flash('Account not found. Please log in again.', 'error')
return redirect('/accountlogin')
stored_hash = account.get('hashed_password', '').encode('utf-8')
if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):
if not bcrypt.checkpw(current_password.encode('utf-8'), account['hashed_password'].encode('utf-8')):
flash('Current password is incorrect.', 'error')
return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
account['hashed_password'] = hashed.decode('utf-8')
account['salt'] = salt.decode('utf-8')
_save_accounts(data)
try:
con = config_utils.open_accounts_db()
con.execute(
'UPDATE accounts SET hashed_password=? WHERE account_id=?',
(hashed, account['account_id'])
)
con.commit()
con.close()
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}')

View file

@ -29,6 +29,7 @@
"name": "email",
"input_type": "text",
"value": "%PREF_EMAIL%",
"disabled": true,
"hint": "Contact your manager to change your email address."
},
{

View file

@ -8,6 +8,6 @@ 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['PREF_TIMEZONE'] = ''
tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES])
return tokens