Development

This commit is contained in:
Matthew Grotke 2026-06-12 11:16:31 -04:00
parent c561f2f548
commit 1beb660be1
8 changed files with 280 additions and 48 deletions

View file

@ -43,6 +43,7 @@ def init_accounts_db():
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
access_level INTEGER NOT NULL, access_level INTEGER NOT NULL,
hashed_password TEXT, hashed_password TEXT,
requested_email TEXT,
created_ts INTEGER NOT NULL, created_ts INTEGER NOT NULL,
created_by TEXT NOT NULL created_by TEXT NOT NULL
); );
@ -68,6 +69,11 @@ def init_accounts_db():
); );
''') ''')
con.commit() con.commit()
try:
con.execute('ALTER TABLE accounts ADD COLUMN requested_email TEXT')
con.commit()
except Exception:
pass
con.close() con.close()
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} _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_int'] = d.get('access_level', 1)
d['access_level'] = _LEVEL_INT_TO_STR.get(d['access_level_int'], 'viewer') 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['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', '') d['account_created_by'] = d.get('created_by', '')
ts = d.get('created_ts', 0) ts = d.get('created_ts', 0)
try: try:

View file

@ -31,6 +31,33 @@ def form_login():
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
if not account.get('hashed_password'): 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') flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')

View file

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import os, re, sqlite3 import os, re, secrets, sqlite3, time
from datetime import datetime, timezone from datetime import datetime, timezone
import auth import auth
import config_utils import config_utils
@ -13,6 +13,93 @@ bp = Blueprint(_PAGE, __name__)
VALID_LEVELS = {'viewer': 1, 'administrator': 2, 'manager': 3} 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']) @bp.route('/action/accountmanage/session_invalidate', methods=['POST'])
@auth.require_level('manager') @auth.require_level('manager')
def session_invalidate(): def session_invalidate():

View file

@ -25,34 +25,8 @@
"label": "User Accounts", "label": "User Accounts",
"items": [ "items": [
{ {
"type": "table", "type": "raw_html",
"datasource": "config:accounts", "html": "%ACCOUNTS_TABLE%"
"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"
}
]
} }
] ]
}, },

View file

@ -81,6 +81,76 @@ def _active_sessions_table():
) )
def _accounts_table():
accounts = config_utils.list_accounts()
if not accounts:
return '<p class="text-muted" style="margin:0">No accounts.</p>'
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'<form method="post" action="/action/accountmanage/email_change_approve"'
f' style="display:inline;margin:0">'
f'<input type="hidden" name="account_id" value="{factory.e(acct_id)}">'
f'<button type="submit" class="btn-link">Approve</button>'
f'</form>'
)
deny_btn = (
f'<form method="post" action="/action/accountmanage/email_change_deny"'
f' style="display:inline;margin:0">'
f'<input type="hidden" name="account_id" value="{factory.e(acct_id)}">'
f'<button type="submit" class="btn-link btn-link-danger">Deny</button>'
f'</form>'
)
email_cell = (
f'{factory.e(email)}<br>'
f'<span class="text-muted" style="font-size:0.85em">'
f'{approve_btn} or {deny_btn} change to: {factory.e(req_email)}'
f'</span>'
)
else:
email_cell = factory.e(email)
row_json = factory.e(json.dumps({'email_address': email, 'access_level': level}))
edit_btn = (
f'<button type="button" class="btn btn-ghost btn-sm row-edit-btn"'
f' data-row-index="{i}" data-row="{row_json}"'
f' data-target="edit-form">Edit</button>'
)
delete_btn = (
f'<form method="post" action="/action/accountmanage/accounts_delete"'
f' class="form-inline">'
f'<input type="hidden" name="row_index" value="{i}">'
f'<button type="submit" class="btn btn-danger btn-sm">Delete</button>'
f'</form>'
)
trs += (
f'<tr>'
f'<td class="table-cell">{email_cell}</td>'
f'<td class="table-cell">{factory.e(level)}</td>'
f'<td class="table-cell">{factory.e(status)}</td>'
f'<td class="col-actions">{edit_btn} {delete_btn}</td>'
f'</tr>'
)
return (
'<table class="data-table"><thead><tr>'
'<th class="table-header">Email</th>'
'<th class="table-header">Access Level</th>'
'<th class="table-header">Status</th>'
'<th class="table-header"></th>'
'</tr></thead><tbody>' + trs + '</tbody></table>'
)
def collect_tokens(cfg): def collect_tokens(cfg):
tokens = config_utils.collect_layout_tokens(cfg) tokens = config_utils.collect_layout_tokens(cfg)
tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([ tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([
@ -89,6 +159,7 @@ def collect_tokens(cfg):
{'value': 'manager', 'label': 'Manager (full access including account management)'}, {'value': 'manager', 'label': 'Manager (full access including account management)'},
]) ])
tokens['ACTIVE_SESSIONS_TABLE'] = _active_sessions_table() tokens['ACTIVE_SESSIONS_TABLE'] = _active_sessions_table()
tokens['ACCOUNTS_TABLE'] = _accounts_table()
content = factory.load_json(f'{factory.PAGES_DIR}/accountmanage/content.json') content = factory.load_json(f'{factory.PAGES_DIR}/accountmanage/content.json')
for table_item in factory.iter_table_items(content.get('items', [])): for table_item in factory.iter_table_items(content.get('items', [])):
ds = table_item.get('datasource', '') ds = table_item.get('datasource', '')

View file

@ -37,6 +37,39 @@ def accountdetails_save():
return redirect(f'/{_PAGE}') 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']) @bp.route('/action/preferences/changepassword_save', methods=['POST'])
@auth.require_level('viewer') @auth.require_level('viewer')
def changepassword_save(): def changepassword_save():

View file

@ -23,31 +23,19 @@
"action": "/action/preferences/accountdetails_save", "action": "/action/preferences/accountdetails_save",
"method": "post", "method": "post",
"items": [ "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", "type": "field",
"label": "Timezone", "label": "Timezone",
"name": "timezone", "name": "timezone",
"input_type": "select", "input_type": "select",
"value": "%PREF_TIMEZONE%", "value": "%PREF_TIMEZONE%",
"options": "%TIMEZONE_OPTIONS%", "options": "%TIMEZONE_OPTIONS%"
"hint": "All timestamps will be displayed in this timezone."
}, },
{ {
"type": "button_row", "type": "button_row",
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/preferences/accountdetails_save",
"method": "post",
"text": "Save Preferences" "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", "type": "card",
"label": "Change Password", "label": "Change Password",
@ -91,8 +113,6 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/preferences/changepassword_save",
"method": "post",
"text": "Change Password" "text": "Change Password"
} }
] ]

View file

@ -2,6 +2,7 @@ import json
from flask import session from flask import session
import sanitize import sanitize
import config_utils import config_utils
import factory
def collect_tokens(cfg): def collect_tokens(cfg):
@ -10,4 +11,16 @@ def collect_tokens(cfg):
tokens['PREF_EMAIL'] = session.get('email_address', '') tokens['PREF_EMAIL'] = session.get('email_address', '')
tokens['PREF_TIMEZONE'] = session.get('timezone', '') tokens['PREF_TIMEZONE'] = session.get('timezone', '')
tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) 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'<div class="info-bar info-bar-inline info-bar-warning">'
f'A request to change email to {factory.e(requested)} is pending approval.'
f'</div>'
)
else:
tokens['PENDING_EMAIL_BAR'] = ''
return tokens return tokens