Merge feature/user-access-control into develop

This commit is contained in:
nickviljoen 2026-04-22 12:33:30 +02:00
commit c4a578500c
6 changed files with 840 additions and 48 deletions

11
.gitignore vendored
View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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');