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

@ -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():

View file

@ -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%"
}
]
},

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):
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', '')