Merge feature/user-access-control into develop
This commit is contained in:
commit
c4a578500c
6 changed files with 840 additions and 48 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -62,3 +62,14 @@ output-dev/
|
|||
fix_apache_proxy.sh
|
||||
diagnose_auth_issue.sh
|
||||
|
||||
|
||||
# Local working docs and runtime state
|
||||
CAPABILITY_LIST.md
|
||||
COMPLIANCE_DOCUMENTATION.md
|
||||
IMPLEMENTATION_TEST_RESULTS.md
|
||||
LOREAL_STATIC_GENERAL_IMPLEMENTATION.md
|
||||
OCR_CALIBRATION_TODO.md
|
||||
backend/debug_mode.txt
|
||||
backend/media_plans/
|
||||
backend/usage_logs/
|
||||
backend/user_access.json
|
||||
|
|
|
|||
|
|
@ -1726,6 +1726,10 @@ def start_analysis():
|
|||
else:
|
||||
client = 'general'
|
||||
|
||||
access_err = _require_client_access(client)
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
# Log analysis start
|
||||
try:
|
||||
from usage_tracker import log_analysis_start
|
||||
|
|
@ -2078,8 +2082,12 @@ def start_analysis():
|
|||
}), 500
|
||||
|
||||
@app.route('/output/<client>/<filename>', methods=['GET'])
|
||||
@auth.require_auth
|
||||
def serve_client_output_file(client, filename):
|
||||
"""Serve saved output files from client-specific folders"""
|
||||
access_err = _require_client_access(client)
|
||||
if access_err:
|
||||
return access_err
|
||||
try:
|
||||
file_path = os.path.join(app.config['OUTPUT_FOLDER'], client, filename)
|
||||
if os.path.exists(file_path):
|
||||
|
|
@ -2121,6 +2129,7 @@ def serve_output_file(filename):
|
|||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/output_files', methods=['GET'])
|
||||
@auth.require_auth
|
||||
def list_output_files():
|
||||
"""List saved output files filtered by client, sorted by creation date (newest first)"""
|
||||
try:
|
||||
|
|
@ -2128,6 +2137,11 @@ def list_output_files():
|
|||
client_filter = request.args.get('client', None)
|
||||
print(f"DEBUG: list_output_files called with client_filter='{client_filter}'")
|
||||
|
||||
if client_filter:
|
||||
access_err = _require_client_access(client_filter)
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
# Run cleanup of files older than 14 days
|
||||
cleanup_old_files(max_age_days=14)
|
||||
|
||||
|
|
@ -2318,14 +2332,34 @@ def get_available_profiles():
|
|||
|
||||
@app.route('/api/clients', methods=['GET'])
|
||||
def get_clients_endpoint():
|
||||
"""Get all available clients and their configurations"""
|
||||
"""Get clients visible to the current user. Admins see all; others see only their grants."""
|
||||
from client_config import get_all_clients
|
||||
from user_access import get_user_clients, is_admin
|
||||
|
||||
try:
|
||||
clients = get_all_clients()
|
||||
all_clients = get_all_clients()
|
||||
|
||||
# Resolve the current user's email if authenticated. Unauthenticated
|
||||
# callers get an empty list so the UI can prompt for sign-in without
|
||||
# leaking the full client catalogue.
|
||||
user_email = ''
|
||||
try:
|
||||
auth_result = app.auth_middleware.is_authenticated()
|
||||
if auth_result.get('authenticated'):
|
||||
user_email = auth_result.get('user', {}).get('email', '')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not user_email:
|
||||
return jsonify({'status': 'success', 'clients': {}, 'is_admin': False})
|
||||
|
||||
allowed_ids = get_user_clients(user_email)
|
||||
filtered = {cid: all_clients[cid] for cid in allowed_ids if cid in all_clients}
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'clients': clients
|
||||
'clients': filtered,
|
||||
'is_admin': is_admin(user_email)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
|
|
@ -4205,6 +4239,10 @@ def upload_media_plan():
|
|||
if not client_id:
|
||||
return jsonify({'status': 'error', 'message': 'client_id is required'}), 400
|
||||
|
||||
access_err = _require_client_access(client_id)
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
# Save the Excel file
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', file.filename)
|
||||
|
|
@ -4261,12 +4299,17 @@ def upload_media_plan():
|
|||
|
||||
|
||||
@app.route('/api/media_plan', methods=['GET'])
|
||||
@auth.require_auth
|
||||
def get_media_plan():
|
||||
"""Get the active media plan for a client"""
|
||||
client_id = request.args.get('client', '').strip().lower()
|
||||
if not client_id:
|
||||
return jsonify({'status': 'error', 'message': 'client parameter required'}), 400
|
||||
|
||||
access_err = _require_client_access(client_id)
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
db = _load_media_plans_db()
|
||||
plan_info = db.get(client_id)
|
||||
if not plan_info:
|
||||
|
|
@ -4287,6 +4330,9 @@ def get_media_plan():
|
|||
@auth.require_auth
|
||||
def delete_media_plan(client_id):
|
||||
"""Delete the media plan for a client"""
|
||||
access_err = _require_client_access(client_id)
|
||||
if access_err:
|
||||
return access_err
|
||||
db = _load_media_plans_db()
|
||||
plan_info = db.get(client_id)
|
||||
if not plan_info:
|
||||
|
|
@ -4473,6 +4519,10 @@ def get_client_usage_stats():
|
|||
if not client:
|
||||
return jsonify({'status': 'error', 'message': 'client parameter required'}), 400
|
||||
|
||||
access_err = _require_client_access(client)
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
|
|
@ -4533,7 +4583,7 @@ def get_client_usage_stats():
|
|||
@auth.require_auth
|
||||
def check_admin():
|
||||
"""Check if the current user is an admin"""
|
||||
from client_config import is_admin
|
||||
from user_access import is_admin
|
||||
user_email = getattr(g, 'user', {}).get('email', '')
|
||||
return jsonify({'is_admin': is_admin(user_email)})
|
||||
|
||||
|
|
@ -4542,7 +4592,7 @@ def check_admin():
|
|||
@auth.require_auth
|
||||
def get_admin_users():
|
||||
"""Get all users who have accessed the platform (admin only)"""
|
||||
from client_config import is_admin
|
||||
from user_access import is_admin
|
||||
user_email = getattr(g, 'user', {}).get('email', '')
|
||||
if not is_admin(user_email):
|
||||
return jsonify({'status': 'error', 'message': 'Admin access required'}), 403
|
||||
|
|
@ -4621,6 +4671,165 @@ def get_admin_users():
|
|||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""Return (user_email, None) if admin, else (None, error_response)."""
|
||||
from user_access import is_admin
|
||||
user_email = getattr(g, 'user', {}).get('email', '')
|
||||
if not is_admin(user_email):
|
||||
return None, (jsonify({'status': 'error', 'message': 'Admin access required'}), 403)
|
||||
return user_email, None
|
||||
|
||||
|
||||
def _require_client_access(client_id):
|
||||
"""
|
||||
Validate the authed user can access `client_id`.
|
||||
Returns None on success, or a (response, status) tuple to short-circuit the endpoint.
|
||||
"""
|
||||
if not client_id:
|
||||
return None # endpoint didn't scope by client
|
||||
from user_access import get_user_clients
|
||||
user_email = getattr(g, 'user', {}).get('email', '')
|
||||
if not user_email:
|
||||
return jsonify({'status': 'error', 'message': 'Authentication required'}), 401
|
||||
allowed = get_user_clients(user_email)
|
||||
if client_id not in allowed:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'code': 'client_access_denied',
|
||||
'message': f'You do not have access to client "{client_id}"'
|
||||
}), 403
|
||||
return None
|
||||
|
||||
|
||||
@app.route('/api/admin/user_access', methods=['GET'])
|
||||
@auth.require_auth
|
||||
def get_user_access_list():
|
||||
"""List all users with their client grants. Joins login-log users with explicit grants."""
|
||||
_, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
from user_access import list_access_entries
|
||||
from generate_usage_report import load_logs
|
||||
|
||||
access = list_access_entries()
|
||||
entries_by_email = {e['email'].lower(): e for e in access['entries']}
|
||||
|
||||
# Enrich with login-log data (name + last_active) for users who have signed in
|
||||
login_users = {}
|
||||
for log_entry in load_logs():
|
||||
email = log_entry.get('user_email')
|
||||
if not email:
|
||||
continue
|
||||
lower = email.lower()
|
||||
ts = log_entry.get('timestamp', '')
|
||||
if lower not in login_users or ts > login_users[lower].get('last_active', ''):
|
||||
login_users[lower] = {
|
||||
'email': email,
|
||||
'name': log_entry.get('user_name', ''),
|
||||
'last_active': ts
|
||||
}
|
||||
|
||||
# Merge: everyone in login logs + everyone with an explicit grant
|
||||
all_emails = set(login_users.keys()) | set(entries_by_email.keys())
|
||||
merged = []
|
||||
for lower in all_emails:
|
||||
entry = entries_by_email.get(lower, {
|
||||
'email': login_users.get(lower, {}).get('email', lower),
|
||||
'clients': access['default_clients'],
|
||||
'is_admin': False,
|
||||
'updated_at': None,
|
||||
'updated_by': None
|
||||
})
|
||||
login = login_users.get(lower, {})
|
||||
merged.append({
|
||||
**entry,
|
||||
'name': login.get('name', ''),
|
||||
'last_active': login.get('last_active', ''),
|
||||
'has_explicit_grant': lower in entries_by_email
|
||||
})
|
||||
|
||||
merged.sort(key=lambda x: (not x.get('is_admin'), x.get('email', '').lower()))
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'default_clients': access['default_clients'],
|
||||
'users': merged
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error listing user access: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/user_access/<path:target_email>', methods=['PUT'])
|
||||
@auth.require_auth
|
||||
def set_user_access(target_email):
|
||||
"""Set the list of clients a user can see. Body: {"clients": ["general", ...]}"""
|
||||
actor_email, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
clients = data.get('clients')
|
||||
if not isinstance(clients, list):
|
||||
return jsonify({'status': 'error', 'message': 'clients must be a list'}), 400
|
||||
|
||||
try:
|
||||
from user_access import set_user_clients
|
||||
from usage_tracker import log_access_change
|
||||
audit = set_user_clients(target_email, clients, actor_email)
|
||||
log_access_change(audit)
|
||||
return jsonify({'status': 'success', 'audit': audit})
|
||||
except ValueError as ve:
|
||||
return jsonify({'status': 'error', 'message': str(ve)}), 400
|
||||
except Exception as e:
|
||||
print(f"Error setting user access: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/user_access/<path:target_email>/promote', methods=['POST'])
|
||||
@auth.require_auth
|
||||
def promote_user_admin(target_email):
|
||||
"""Promote a user to admin."""
|
||||
actor_email, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
from user_access import promote_admin
|
||||
from usage_tracker import log_access_change
|
||||
audit = promote_admin(target_email, actor_email)
|
||||
log_access_change(audit)
|
||||
return jsonify({'status': 'success', 'audit': audit})
|
||||
except ValueError as ve:
|
||||
return jsonify({'status': 'error', 'message': str(ve)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/user_access/<path:target_email>/demote', methods=['POST'])
|
||||
@auth.require_auth
|
||||
def demote_user_admin(target_email):
|
||||
"""Remove admin role from a user. Blocked if it would leave zero admins."""
|
||||
actor_email, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
from user_access import demote_admin
|
||||
from usage_tracker import log_access_change
|
||||
audit = demote_admin(target_email, actor_email)
|
||||
log_access_change(audit)
|
||||
return jsonify({'status': 'success', 'audit': audit})
|
||||
except ValueError as ve:
|
||||
return jsonify({'status': 'error', 'message': str(ve)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/consolidate_reports', methods=['POST'])
|
||||
@auth.require_auth
|
||||
def consolidate_reports():
|
||||
|
|
|
|||
|
|
@ -121,13 +121,9 @@ def get_profiles_with_visibility(client_id):
|
|||
return available_profiles
|
||||
|
||||
|
||||
# Admin user configuration
|
||||
ADMIN_USERS = [
|
||||
'nick.viljoen@brandtech.plus',
|
||||
]
|
||||
|
||||
# Admin membership now lives in backend/user_access.json.
|
||||
# Kept as a thin shim so any older callers keep working.
|
||||
def is_admin(email):
|
||||
"""Check if a user email is an admin"""
|
||||
if not email:
|
||||
return False
|
||||
return email.lower() in [e.lower() for e in ADMIN_USERS]
|
||||
"""Deprecated — delegates to user_access.is_admin()."""
|
||||
from user_access import is_admin as _is_admin
|
||||
return _is_admin(email)
|
||||
|
|
|
|||
|
|
@ -158,6 +158,22 @@ def log_user_login(user_info):
|
|||
return log_entry
|
||||
|
||||
|
||||
def log_access_change(audit_entry):
|
||||
"""
|
||||
Log an access grant/revoke/promote/demote event.
|
||||
|
||||
Args:
|
||||
audit_entry: dict from user_access.set_user_clients / promote_admin / demote_admin
|
||||
"""
|
||||
log_entry = {
|
||||
'event': 'access_change',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
**audit_entry
|
||||
}
|
||||
_write_log_entry(log_entry)
|
||||
return log_entry
|
||||
|
||||
|
||||
def _calculate_analysis_cost(results):
|
||||
"""
|
||||
Calculate cost based on actual token usage from LLM responses
|
||||
|
|
|
|||
243
backend/user_access.py
Normal file
243
backend/user_access.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
User access control module.
|
||||
|
||||
Stores per-user client visibility grants and admin role in user_access.json.
|
||||
Users not listed fall back to default_clients (typically ['general']).
|
||||
Admins see all clients regardless of their grant list.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
ACCESS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'user_access.json')
|
||||
BOOTSTRAP_ADMIN = 'nick.viljoen@brandtech.plus'
|
||||
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def _default_data():
|
||||
return {
|
||||
'version': 1,
|
||||
'default_clients': ['general'],
|
||||
'admins': [BOOTSTRAP_ADMIN],
|
||||
'users': {}
|
||||
}
|
||||
|
||||
|
||||
def _load():
|
||||
"""Load access data. Creates the file with bootstrap defaults if missing or corrupt."""
|
||||
if not os.path.exists(ACCESS_FILE):
|
||||
data = _default_data()
|
||||
_save(data)
|
||||
print(f"[user_access] Initialized {ACCESS_FILE} with {BOOTSTRAP_ADMIN} as admin")
|
||||
return data
|
||||
|
||||
try:
|
||||
with open(ACCESS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
# Fill in any missing top-level keys so old files keep working
|
||||
defaults = _default_data()
|
||||
for key, value in defaults.items():
|
||||
if key not in data:
|
||||
data[key] = value
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"[user_access] ERROR reading {ACCESS_FILE}: {e} — falling back to defaults")
|
||||
return _default_data()
|
||||
|
||||
|
||||
def _save(data):
|
||||
with open(ACCESS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _normalize(email):
|
||||
return (email or '').strip().lower()
|
||||
|
||||
|
||||
def get_user_clients(email):
|
||||
"""Return list of client IDs the user can see. Admins see all."""
|
||||
email = _normalize(email)
|
||||
if not email:
|
||||
return []
|
||||
|
||||
with _lock:
|
||||
data = _load()
|
||||
|
||||
# Admins see everything
|
||||
if email in [_normalize(a) for a in data.get('admins', [])]:
|
||||
from client_config import get_all_clients
|
||||
return list(get_all_clients().keys())
|
||||
|
||||
# Explicit grant
|
||||
users = data.get('users', {})
|
||||
for stored_email, entry in users.items():
|
||||
if _normalize(stored_email) == email:
|
||||
return list(entry.get('clients', []))
|
||||
|
||||
# Fall back to default
|
||||
return list(data.get('default_clients', []))
|
||||
|
||||
|
||||
def set_user_clients(target_email, clients, actor_email):
|
||||
"""Grant exactly this set of clients to a user. Returns the audit entry."""
|
||||
target_email = _normalize(target_email)
|
||||
actor_email = _normalize(actor_email)
|
||||
if not target_email:
|
||||
raise ValueError("target_email is required")
|
||||
|
||||
# Validate clients exist
|
||||
from client_config import get_all_clients
|
||||
valid_clients = set(get_all_clients().keys())
|
||||
for client_id in clients:
|
||||
if client_id not in valid_clients:
|
||||
raise ValueError(f"Unknown client: {client_id}")
|
||||
|
||||
with _lock:
|
||||
data = _load()
|
||||
|
||||
# Find existing entry (case-insensitive) or add new one
|
||||
existing_key = None
|
||||
for stored_email in data.get('users', {}).keys():
|
||||
if _normalize(stored_email) == target_email:
|
||||
existing_key = stored_email
|
||||
break
|
||||
|
||||
clients_before = list(data['users'].get(existing_key, {}).get('clients', [])) if existing_key else list(data.get('default_clients', []))
|
||||
|
||||
if existing_key and existing_key != target_email:
|
||||
del data['users'][existing_key]
|
||||
|
||||
data['users'][target_email] = {
|
||||
'clients': list(clients),
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'updated_by': actor_email
|
||||
}
|
||||
_save(data)
|
||||
|
||||
return {
|
||||
'action': 'grant' if len(clients) >= len(clients_before) else 'revoke',
|
||||
'target_email': target_email,
|
||||
'actor_email': actor_email,
|
||||
'clients_before': clients_before,
|
||||
'clients_after': list(clients)
|
||||
}
|
||||
|
||||
|
||||
def is_admin(email):
|
||||
"""Check if a user is an admin."""
|
||||
email = _normalize(email)
|
||||
if not email:
|
||||
return False
|
||||
with _lock:
|
||||
data = _load()
|
||||
return email in [_normalize(a) for a in data.get('admins', [])]
|
||||
|
||||
|
||||
def promote_admin(target_email, actor_email):
|
||||
"""Promote a user to admin. Returns audit entry."""
|
||||
target_email = _normalize(target_email)
|
||||
actor_email = _normalize(actor_email)
|
||||
if not target_email:
|
||||
raise ValueError("target_email is required")
|
||||
|
||||
with _lock:
|
||||
data = _load()
|
||||
admins_lower = [_normalize(a) for a in data.get('admins', [])]
|
||||
if target_email in admins_lower:
|
||||
return {
|
||||
'action': 'promote_admin',
|
||||
'target_email': target_email,
|
||||
'actor_email': actor_email,
|
||||
'no_op': True
|
||||
}
|
||||
data.setdefault('admins', []).append(target_email)
|
||||
_save(data)
|
||||
|
||||
return {
|
||||
'action': 'promote_admin',
|
||||
'target_email': target_email,
|
||||
'actor_email': actor_email
|
||||
}
|
||||
|
||||
|
||||
def demote_admin(target_email, actor_email):
|
||||
"""
|
||||
Demote an admin. At least one admin must remain.
|
||||
Blocks self-demote if the actor is the last admin.
|
||||
"""
|
||||
target_email = _normalize(target_email)
|
||||
actor_email = _normalize(actor_email)
|
||||
if not target_email:
|
||||
raise ValueError("target_email is required")
|
||||
|
||||
with _lock:
|
||||
data = _load()
|
||||
admins = data.get('admins', [])
|
||||
admins_lower = [_normalize(a) for a in admins]
|
||||
|
||||
if target_email not in admins_lower:
|
||||
return {
|
||||
'action': 'demote_admin',
|
||||
'target_email': target_email,
|
||||
'actor_email': actor_email,
|
||||
'no_op': True
|
||||
}
|
||||
|
||||
if len(admins) <= 1:
|
||||
raise ValueError("Cannot demote the last admin. Promote another user first.")
|
||||
|
||||
data['admins'] = [a for a in admins if _normalize(a) != target_email]
|
||||
_save(data)
|
||||
|
||||
return {
|
||||
'action': 'demote_admin',
|
||||
'target_email': target_email,
|
||||
'actor_email': actor_email
|
||||
}
|
||||
|
||||
|
||||
def list_access_entries():
|
||||
"""
|
||||
Return all explicit access entries plus admin flags.
|
||||
Used by the admin panel to render the users table.
|
||||
"""
|
||||
with _lock:
|
||||
data = _load()
|
||||
|
||||
admins_lower = [_normalize(a) for a in data.get('admins', [])]
|
||||
entries = []
|
||||
for email, entry in data.get('users', {}).items():
|
||||
entries.append({
|
||||
'email': email,
|
||||
'clients': list(entry.get('clients', [])),
|
||||
'is_admin': _normalize(email) in admins_lower,
|
||||
'updated_at': entry.get('updated_at'),
|
||||
'updated_by': entry.get('updated_by')
|
||||
})
|
||||
|
||||
# Include admins who have no explicit grant row yet
|
||||
explicit_emails = {_normalize(e['email']) for e in entries}
|
||||
for admin_email in data.get('admins', []):
|
||||
if _normalize(admin_email) not in explicit_emails:
|
||||
entries.append({
|
||||
'email': admin_email,
|
||||
'clients': [],
|
||||
'is_admin': True,
|
||||
'updated_at': None,
|
||||
'updated_by': None
|
||||
})
|
||||
|
||||
return {
|
||||
'default_clients': list(data.get('default_clients', [])),
|
||||
'entries': entries
|
||||
}
|
||||
|
||||
|
||||
def get_access_snapshot():
|
||||
"""Raw view of the file — used by audit logging for clients_before."""
|
||||
with _lock:
|
||||
return _load()
|
||||
385
web_ui.html
385
web_ui.html
|
|
@ -938,50 +938,92 @@
|
|||
|
||||
<!-- Admin Section (full page, admin users only) -->
|
||||
<div id="adminSection" style="display: none;">
|
||||
<div style="max-width: 1000px; margin: 0 auto;">
|
||||
<div style="max-width: 1100px; margin: 0 auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
|
||||
<div>
|
||||
<h2 style="color: #333; margin-bottom: 5px;">Administration Panel</h2>
|
||||
<p style="color: #6c757d;">Platform user activity overview</p>
|
||||
<p style="color: #6c757d;">Platform user activity and access control</p>
|
||||
</div>
|
||||
<button onclick="hideAdminSection()" class="settings-btn" style="background: #6c757d; color: white; border-color: #6c757d;">
|
||||
Back to App
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-summary-cards" id="adminSummaryCards" style="padding: 0; margin-bottom: 25px;">
|
||||
<div class="reporting-card" style="background: #e3f2fd;">
|
||||
<div class="card-value" style="color: #1565c0;" id="adminTotalUsers">-</div>
|
||||
<div class="card-label" style="color: #1565c0;">Total Users</div>
|
||||
<div class="admin-tabs" style="border-bottom: 2px solid #e9ecef; margin-bottom: 25px; display: flex; gap: 8px;">
|
||||
<button class="admin-tab-btn active" data-admin-tab="usage" onclick="showAdminTab('usage')" style="background: none; border: none; padding: 10px 18px; cursor: pointer; font-size: 14px; color: #007bff; border-bottom: 3px solid #007bff; margin-bottom: -2px; font-weight: 600;">
|
||||
Usage Overview
|
||||
</button>
|
||||
<button class="admin-tab-btn" data-admin-tab="access" onclick="showAdminTab('access')" style="background: none; border: none; padding: 10px 18px; cursor: pointer; font-size: 14px; color: #666; border-bottom: 3px solid transparent; margin-bottom: -2px;">
|
||||
User Access
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="adminUsageTab" class="admin-tab-content">
|
||||
<div class="admin-summary-cards" id="adminSummaryCards" style="padding: 0; margin-bottom: 25px;">
|
||||
<div class="reporting-card" style="background: #e3f2fd;">
|
||||
<div class="card-value" style="color: #1565c0;" id="adminTotalUsers">-</div>
|
||||
<div class="card-label" style="color: #1565c0;">Total Users</div>
|
||||
</div>
|
||||
<div class="reporting-card" style="background: #e8f5e9;">
|
||||
<div class="card-value" style="color: #2e7d32;" id="adminTotalAnalyses">-</div>
|
||||
<div class="card-label" style="color: #2e7d32;">Total Platform Analyses</div>
|
||||
</div>
|
||||
<div class="reporting-card" style="background: #fce4ec;">
|
||||
<div class="card-value" style="color: #c62828;" id="adminTotalCost">-</div>
|
||||
<div class="card-label" style="color: #c62828;">Total Estimated Cost</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reporting-card" style="background: #e8f5e9;">
|
||||
<div class="card-value" style="color: #2e7d32;" id="adminTotalAnalyses">-</div>
|
||||
<div class="card-label" style="color: #2e7d32;">Total Platform Analyses</div>
|
||||
</div>
|
||||
<div class="reporting-card" style="background: #fce4ec;">
|
||||
<div class="card-value" style="color: #c62828;" id="adminTotalCost">-</div>
|
||||
<div class="card-label" style="color: #c62828;">Total Estimated Cost</div>
|
||||
|
||||
<h3 style="color: #495057; margin-bottom: 15px;">Platform Users</h3>
|
||||
<div id="adminUserTableContainer" style="max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 10px;">
|
||||
<table class="admin-user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th style="text-align: center;">Analyses</th>
|
||||
<th style="text-align: center;">Total Checks</th>
|
||||
<th>Clients Used</th>
|
||||
<th>Last Active</th>
|
||||
<th style="text-align: right;">Est. Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="adminUserTableBody">
|
||||
<tr><td colspan="7" style="text-align: center; padding: 30px; color: #6c757d;">Loading users...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="color: #495057; margin-bottom: 15px;">Platform Users</h3>
|
||||
<div id="adminUserTableContainer" style="max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 10px;">
|
||||
<table class="admin-user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th style="text-align: center;">Analyses</th>
|
||||
<th style="text-align: center;">Total Checks</th>
|
||||
<th>Clients Used</th>
|
||||
<th>Last Active</th>
|
||||
<th style="text-align: right;">Est. Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="adminUserTableBody">
|
||||
<tr><td colspan="7" style="text-align: center; padding: 30px; color: #6c757d;">Loading users...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="adminAccessTab" class="admin-tab-content" style="display: none;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; gap: 15px;">
|
||||
<input type="text" id="accessSearchInput" placeholder="Search by email or name..." oninput="renderAccessTable()" style="flex: 1; padding: 10px 14px; border: 1px solid #ced4da; border-radius: 8px; font-size: 14px;">
|
||||
<button onclick="showAddAccessUser()" class="settings-btn" style="background: #007bff; color: white; border-color: #007bff; white-space: nowrap;">
|
||||
+ Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="accessDefaultBanner" style="background: #fff3cd; border: 1px solid #ffeeba; color: #856404; padding: 10px 14px; border-radius: 8px; margin-bottom: 15px; font-size: 13px;">
|
||||
<strong>Default access:</strong> <span id="accessDefaultClients">general</span>. Users not listed below automatically see only these clients.
|
||||
</div>
|
||||
|
||||
<div id="accessTableContainer" style="max-height: 520px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 10px;">
|
||||
<table class="admin-user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th style="text-align: center; width: 70px;">Admin</th>
|
||||
<th>Clients Visible</th>
|
||||
<th>Last Updated</th>
|
||||
<th style="text-align: right; width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accessTableBody">
|
||||
<tr><td colspan="6" style="text-align: center; padding: 30px; color: #6c757d;">Loading users...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1288,6 +1330,38 @@
|
|||
loadClients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a client-access-denied response from any API call.
|
||||
* Returns true if the response signalled client_access_denied (caller should stop).
|
||||
*/
|
||||
async function handleClientAccessDenied(response) {
|
||||
if (response.status !== 403) return false;
|
||||
let body;
|
||||
try { body = await response.clone().json(); } catch (_) { return false; }
|
||||
if (body && body.code === 'client_access_denied') {
|
||||
const lost = selectedClient;
|
||||
selectedClient = null;
|
||||
localStorage.removeItem('selectedClient');
|
||||
showAccessRevokedToast(lost);
|
||||
showClientSelector();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showAccessRevokedToast(clientId) {
|
||||
let toast = document.getElementById('revokedToast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'revokedToast';
|
||||
toast.style.cssText = 'position: fixed; top: 25px; left: 50%; transform: translateX(-50%); background: #dc3545; color: white; padding: 14px 24px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 10000; font-size: 14px;';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = `Your access to "${clientId}" was removed. Please pick a different client.`;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { if (toast) toast.style.display = 'none'; }, 6000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup client switch button handler
|
||||
*/
|
||||
|
|
@ -1503,6 +1577,11 @@
|
|||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (await handleClientAccessDenied(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Abort if client changed while we were fetching
|
||||
|
|
@ -2252,9 +2331,13 @@
|
|||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
|
||||
console.log('Analysis response status:', response.status);
|
||||
|
||||
|
||||
if (await handleClientAccessDenied(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Analysis failed with status ${response.status}`);
|
||||
}
|
||||
|
|
@ -3206,6 +3289,9 @@
|
|||
|
||||
try {
|
||||
const response = await fetch(BASE_PATH + 'api/media_plan?client=' + selectedClient, { credentials: 'include' });
|
||||
if (await handleClientAccessDenied(response)) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.plan) {
|
||||
|
|
@ -3809,7 +3895,25 @@
|
|||
document.getElementById('clientSelector').style.display = 'none';
|
||||
document.getElementById('authRequired').style.display = 'none';
|
||||
document.getElementById('adminSection').style.display = 'block';
|
||||
loadAdminUsers();
|
||||
showAdminTab('usage');
|
||||
}
|
||||
|
||||
function showAdminTab(tab) {
|
||||
document.querySelectorAll('.admin-tab-btn').forEach(btn => {
|
||||
const active = btn.dataset.adminTab === tab;
|
||||
btn.classList.toggle('active', active);
|
||||
btn.style.color = active ? '#007bff' : '#666';
|
||||
btn.style.borderBottomColor = active ? '#007bff' : 'transparent';
|
||||
btn.style.fontWeight = active ? '600' : 'normal';
|
||||
});
|
||||
document.getElementById('adminUsageTab').style.display = tab === 'usage' ? 'block' : 'none';
|
||||
document.getElementById('adminAccessTab').style.display = tab === 'access' ? 'block' : 'none';
|
||||
|
||||
if (tab === 'usage') {
|
||||
loadAdminUsers();
|
||||
} else if (tab === 'access') {
|
||||
loadAccessData();
|
||||
}
|
||||
}
|
||||
|
||||
function hideAdminSection() {
|
||||
|
|
@ -3862,6 +3966,219 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ===== User Access Control =====
|
||||
let accessData = { default_clients: [], users: [] };
|
||||
let allClientsForAccess = {};
|
||||
|
||||
async function loadAccessData() {
|
||||
try {
|
||||
const [accessResp, clientsResp] = await Promise.all([
|
||||
fetch(`${BASE_PATH}api/admin/user_access`, { credentials: 'include' }),
|
||||
fetch(`${BASE_PATH}api/clients`, { credentials: 'include' })
|
||||
]);
|
||||
const accessJson = await accessResp.json();
|
||||
const clientsJson = await clientsResp.json();
|
||||
|
||||
if (accessJson.status !== 'success') throw new Error(accessJson.message || 'Failed to load access data');
|
||||
|
||||
accessData = { default_clients: accessJson.default_clients || [], users: accessJson.users || [] };
|
||||
// Admin users see all clients from /api/clients — use that as the full catalogue
|
||||
allClientsForAccess = clientsJson.clients || {};
|
||||
|
||||
document.getElementById('accessDefaultClients').textContent =
|
||||
accessData.default_clients.length ? accessData.default_clients.join(', ') : '(none)';
|
||||
renderAccessTable();
|
||||
} catch (error) {
|
||||
console.error('Error loading access data:', error);
|
||||
document.getElementById('accessTableBody').innerHTML =
|
||||
'<tr><td colspan="6" style="text-align: center; padding: 30px; color: #dc3545;">Error: ' + error.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccessTable() {
|
||||
const tbody = document.getElementById('accessTableBody');
|
||||
const query = (document.getElementById('accessSearchInput').value || '').toLowerCase().trim();
|
||||
|
||||
const filtered = accessData.users.filter(u => {
|
||||
if (!query) return true;
|
||||
return (u.email || '').toLowerCase().includes(query) ||
|
||||
(u.name || '').toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 30px; color: #6c757d;">No users match</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(u => {
|
||||
const clientsText = u.is_admin
|
||||
? '<em style="color: #6c757d;">all clients (admin)</em>'
|
||||
: (u.clients && u.clients.length ? u.clients.join(', ') : '<em style="color: #6c757d;">none</em>');
|
||||
const updated = u.updated_at
|
||||
? new Date(u.updated_at).toLocaleDateString() + (u.updated_by ? ` by ${u.updated_by}` : '')
|
||||
: '—';
|
||||
const safeEmail = (u.email || '').replace(/'/g, "\\'");
|
||||
return `
|
||||
<tr data-access-row="${u.email}">
|
||||
<td>${escapeHtml(u.name || '')}</td>
|
||||
<td>${escapeHtml(u.email || '')}</td>
|
||||
<td style="text-align: center;">${u.is_admin ? '🔑' : ''}</td>
|
||||
<td>${clientsText}</td>
|
||||
<td style="color: #6c757d; font-size: 12px;">${escapeHtml(updated)}</td>
|
||||
<td style="text-align: right;">
|
||||
<button onclick="openAccessEditor('${safeEmail}')" class="settings-btn" style="padding: 4px 10px; font-size: 12px;">Edit</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function openAccessEditor(email) {
|
||||
const user = accessData.users.find(u => (u.email || '').toLowerCase() === email.toLowerCase());
|
||||
if (!user) return;
|
||||
|
||||
const row = document.querySelector(`tr[data-access-row="${user.email}"]`);
|
||||
if (!row) return;
|
||||
|
||||
// Collapse any other open editor
|
||||
document.querySelectorAll('tr.access-editor-row').forEach(r => r.remove());
|
||||
|
||||
const clientCheckboxes = Object.entries(allClientsForAccess).map(([id, info]) => {
|
||||
const checked = user.is_admin || (user.clients || []).includes(id);
|
||||
const disabled = user.is_admin;
|
||||
return `<label style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: #f8f9fa; border-radius: 6px; cursor: ${disabled ? 'not-allowed' : 'pointer'}; opacity: ${disabled ? 0.6 : 1};">
|
||||
<input type="checkbox" data-client-id="${id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
|
||||
${escapeHtml(info.display_name || info.name || id)}
|
||||
</label>`;
|
||||
}).join('');
|
||||
|
||||
const editorRow = document.createElement('tr');
|
||||
editorRow.className = 'access-editor-row';
|
||||
editorRow.innerHTML = `
|
||||
<td colspan="6" style="background: #f8f9fa; padding: 18px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 14px;">
|
||||
<div style="font-weight: 600;">Editing access for ${escapeHtml(user.email)}</div>
|
||||
<div>
|
||||
<div style="font-size: 13px; color: #495057; margin-bottom: 8px;">Clients visible to this user:</div>
|
||||
<div id="accessEditorClients" style="display: flex; flex-wrap: wrap; gap: 8px;">${clientCheckboxes}</div>
|
||||
${user.is_admin ? '<div style="font-size: 12px; color: #6c757d; margin-top: 8px;">Admins always see all clients. Demote to admin to edit client list.</div>' : ''}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="accessEditorAdmin" ${user.is_admin ? 'checked' : ''}>
|
||||
Grant admin access (sees all clients)
|
||||
</label>
|
||||
</div>
|
||||
<div id="accessEditorError" style="color: #dc3545; font-size: 13px; display: none;"></div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button onclick="closeAccessEditor()" class="settings-btn" style="background: #fff; color: #495057;">Cancel</button>
|
||||
<button onclick="saveAccessEditor('${user.email.replace(/'/g, "\\'")}')" class="settings-btn" style="background: #28a745; color: white; border-color: #28a745;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>`;
|
||||
row.after(editorRow);
|
||||
}
|
||||
|
||||
function closeAccessEditor() {
|
||||
document.querySelectorAll('tr.access-editor-row').forEach(r => r.remove());
|
||||
// If the editor was opened for an unsaved "Add User" row, drop it from the local list
|
||||
const hadUnsaved = accessData.users.some(u => u._unsaved);
|
||||
if (hadUnsaved) {
|
||||
accessData.users = accessData.users.filter(u => !u._unsaved);
|
||||
renderAccessTable();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccessEditor(email) {
|
||||
const errorEl = document.getElementById('accessEditorError');
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
const currentUser = accessData.users.find(u => (u.email || '').toLowerCase() === email.toLowerCase());
|
||||
if (!currentUser) return;
|
||||
|
||||
const wantsAdmin = document.getElementById('accessEditorAdmin').checked;
|
||||
const clients = Array.from(document.querySelectorAll('#accessEditorClients input[type=checkbox]'))
|
||||
.filter(cb => cb.checked && !cb.disabled)
|
||||
.map(cb => cb.dataset.clientId);
|
||||
|
||||
try {
|
||||
// Promote / demote first if admin state changed
|
||||
if (wantsAdmin !== currentUser.is_admin) {
|
||||
const action = wantsAdmin ? 'promote' : 'demote';
|
||||
const resp = await fetch(`${BASE_PATH}api/admin/user_access/${encodeURIComponent(email)}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const body = await resp.json();
|
||||
if (body.status !== 'success') throw new Error(body.message || 'Failed to change admin role');
|
||||
}
|
||||
|
||||
// Set client list (skip if user is/will be admin — admins see all regardless)
|
||||
if (!wantsAdmin) {
|
||||
const resp = await fetch(`${BASE_PATH}api/admin/user_access/${encodeURIComponent(email)}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clients })
|
||||
});
|
||||
const body = await resp.json();
|
||||
if (body.status !== 'success') throw new Error(body.message || 'Failed to save access');
|
||||
}
|
||||
|
||||
showAccessToast(`Saved access for ${email}`);
|
||||
closeAccessEditor();
|
||||
await loadAccessData();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddAccessUser() {
|
||||
const email = prompt('Enter the email address of the user to grant access to:');
|
||||
if (!email || !email.includes('@')) return;
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
|
||||
// If already in the list, just open their editor
|
||||
const existing = accessData.users.find(u => (u.email || '').toLowerCase() === trimmed);
|
||||
if (existing) {
|
||||
openAccessEditor(existing.email);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to the local list (not persisted until they hit Save) and open editor
|
||||
accessData.users.unshift({
|
||||
email: trimmed,
|
||||
name: '',
|
||||
clients: accessData.default_clients,
|
||||
is_admin: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
has_explicit_grant: false,
|
||||
last_active: '',
|
||||
_unsaved: true
|
||||
});
|
||||
renderAccessTable();
|
||||
openAccessEditor(trimmed);
|
||||
}
|
||||
|
||||
function showAccessToast(message) {
|
||||
let toast = document.getElementById('accessToast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'accessToast';
|
||||
toast.style.cssText = 'position: fixed; bottom: 25px; right: 25px; background: #28a745; color: white; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 9999; font-size: 14px; opacity: 0; transition: opacity 0.3s;';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.style.opacity = '1';
|
||||
clearTimeout(toast._hideTimer);
|
||||
toast._hideTimer = setTimeout(() => { toast.style.opacity = '0'; }, 3000);
|
||||
}
|
||||
|
||||
// Update reference assets dropdown to show all uploaded files
|
||||
async function updateReferenceAssetsDropdown() {
|
||||
const referenceAssetSelect = document.getElementById('reference-asset-select');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue