first commit

This commit is contained in:
ghostersk
2025-05-25 20:26:18 +01:00
commit 5375ef6121
77 changed files with 9073 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.venv
database.db
__pycache__/
*.db
*.db.old

28
LICENSE.md Normal file
View File

@@ -0,0 +1,28 @@
Custom License: Non-Commercial Open Source License
Copyright (c) Freebede.com 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to use,
copy, modify, and distribute the Software, subject to the following conditions:
1. **Non-Commercial Use Only**: The Software may only be used for non-commercial purposes.
Commercial use — including but not limited to selling, licensing, offering for a fee,
or integrating into a commercial product or service — is strictly prohibited without
explicit written permission or a commercial license from the copyright holder.
2. **Open Source Derivatives Only**: Any derivative works or redistributions must be
released under this same license, and made fully available in source code form,
free of charge.
3. **Attribution**: You must give appropriate credit to the original author(s),
include a copy of this license, and indicate if changes were made.
4. **Disclaimer**: The Software is provided "as is", without warranty of any kind,
express or implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose, and noninfringement. In no event shall the authors
be liable for any claim, damages, or other liability, whether in an action of contract,
tort, or otherwise, arising from, out of, or in connection with the Software or the use
or other dealings in the Software.
For commercial licensing inquiries, contact: github-projects@freebede.com

5
api/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from flask import Blueprint
api_bp = Blueprint('api', __name__)
from . import routes

52
api/models.py Normal file
View File

@@ -0,0 +1,52 @@
from extensions import db
from datetime import datetime
from flask import current_app
import pytz
# Import models for direct relationship references
import sys
import os
# Add the parent directory to path for imports to work
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from auth.models import Company, ApiKey
from utils.toolbox import get_current_timestamp
def get_current_time_with_timezone():
"""Return current time with the configured timezone"""
return get_current_timestamp()
class Log(db.Model):
__tablename__ = 'api_logs'
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(20), nullable=False)
user_name = db.Column(db.String(50), nullable=False)
computer_name = db.Column(db.String(50), nullable=False)
ip_address = db.Column(db.String(15), nullable=False)
timestamp = db.Column(db.DateTime, nullable=False, default=get_current_time_with_timezone)
retry = db.Column(db.Integer, default=0, nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=True)
api_key_id = db.Column(db.Integer, db.ForeignKey('app_auth_api_keys.id'), nullable=True)
# Relationships
company = db.relationship(Company, backref='logs', foreign_keys=[company_id])
api_key = db.relationship(ApiKey, backref='logs', foreign_keys=[api_key_id])
class ErrorLog(db.Model):
"""Model for storing application error logs"""
id = db.Column(db.Integer, primary_key=True)
level = db.Column(db.String(20), nullable=False)
logger_name = db.Column(db.String(100), nullable=True)
message = db.Column(db.Text, nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
pathname = db.Column(db.String(255), nullable=True)
lineno = db.Column(db.Integer, nullable=True)
request_id = db.Column(db.String(100), nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('app_auth_users.id', ondelete='SET NULL'), nullable=True)
remote_addr = db.Column(db.String(50), nullable=True)
exception = db.Column(db.Text, nullable=True)
def __repr__(self):
return f'<ErrorLog {self.id}: {self.level} - {self.message[:50]}>'
# API-specific models
# Note: ApiKey model is in auth/models.py as it's related to user authentication

234
api/routes.py Normal file
View File

@@ -0,0 +1,234 @@
from flask import Blueprint, request, jsonify, current_app, g
from auth.models import User, ApiKey
from api.models import Log
from extensions import db
from functools import wraps
from datetime import datetime, timezone, timedelta
from api import api_bp
from sqlalchemy import and_, text
import pytz
import logging
# Use the existing blueprint from __init__.py instead of creating a new one
api = api_bp
logger = logging.getLogger(__name__)
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
try:
api_key = request.headers.get('X-API-Key')
if not api_key:
logger.warning('API request without API key', extra={
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'endpoint': request.endpoint,
'method': request.method
})
return jsonify({"message": "No API key provided"}), 401
key = ApiKey.query.filter_by(key=api_key).first()
if not key:
logger.warning('Invalid API key used', extra={
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'endpoint': request.endpoint,
'method': request.method,
'api_key_prefix': api_key[:8] + '...' if len(api_key) > 8 else api_key
})
return jsonify({"message": "Invalid API key"}), 401
# Check if the API key is active
if hasattr(key, 'is_active') and not key.is_active:
logger.warning('Disabled API key used', extra={
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'endpoint': request.endpoint,
'method': request.method,
'api_key_id': key.id,
'company_id': key.company_id
})
return jsonify({"message": "API key has been disabled"}), 401
# Update last used timestamp
key.last_used = datetime.now(pytz.timezone(current_app.config['TIMEZONE']))
# Save the API key and associated company in g object for use in route functions
g.api_key = key
g.company_id = key.company_id
db.session.commit()
return f(*args, **kwargs)
except Exception as e:
logger.exception('Error in API key authentication', extra={
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'endpoint': request.endpoint,
'method': request.method,
'has_api_key': bool(request.headers.get('X-API-Key')),
'error': str(e)
})
return jsonify({"message": "Authentication error occurred"}), 500
return decorated
@api.route('/log_event', methods=['POST'])
@require_api_key
def log_event():
try:
data = request.get_json()
# Parse the timestamp from the request - handle different formats
timestamp_str = data['Timestamp']
timestamp_utc = None
# Try different timestamp formats
formats_to_try = [
'%Y-%m-%dT%H:%M:%S%z', # RFC3339/ISO8601 with timezone offset (from Go app)
'%Y-%m-%dT%H:%M:%SZ', # ISO format with Z (UTC)
'%Y-%m-%d %H:%M:%S %Z', # Format with timezone name
'%Y-%m-%d %H:%M:%S', # Simple format without timezone
'%Y-%m-%d %H:%M:%S%z', # Format with numeric timezone
]
for fmt in formats_to_try:
try:
if fmt == '%Y-%m-%d %H:%M:%S %Z':
# Special handling for timezone names
dt_parts = timestamp_str.rsplit(' ', 1)
if len(dt_parts) == 2:
dt_str, tz_str = dt_parts
# Try to parse the datetime part
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
# Try to convert timezone abbreviation to a pytz timezone
tzinfos = {
'BST': 3600, # British Summer Time (UTC+1)
'GMT': 0, # Greenwich Mean Time
'UTC': 0, # Coordinated Universal Time
# Add more timezone abbreviations as needed
}
if tz_str in tzinfos:
# Create aware datetime with the specified timezone offset
offset = timedelta(seconds=tzinfos[tz_str])
timestamp_utc = dt.replace(tzinfo=timezone(offset))
break
elif fmt == '%Y-%m-%dT%H:%M:%S%z':
# Handle RFC3339 format from Go application
timestamp_utc = datetime.strptime(timestamp_str, fmt)
break
else:
timestamp_utc = datetime.strptime(timestamp_str, fmt)
if fmt == '%Y-%m-%d %H:%M:%S': # No timezone info, assume UTC
timestamp_utc = pytz.utc.localize(timestamp_utc)
break
except ValueError:
continue
if timestamp_utc is None:
return jsonify({'message': f'Could not parse timestamp: {timestamp_str}', 'status': 'error'}), 400
# Convert to the configured timezone
from utils.toolbox import get_app_timezone
app_timezone = get_app_timezone()
if timestamp_utc.tzinfo is None:
timestamp_utc = pytz.utc.localize(timestamp_utc)
timestamp = timestamp_utc.astimezone(app_timezone)
# Check if this is a retry attempt
is_retry = data.get('retry', 0)
# Check if a record with the same attributes already exists
existing_log = Log.query.filter(
and_(
Log.event_type == data['EventType'],
Log.user_name == data['UserName'],
Log.computer_name == data['ComputerName'],
Log.ip_address == data['IPAddress'],
Log.timestamp == timestamp
)
).first()
if existing_log:
# Record already exists, don't create duplicate
return jsonify({'message': 'Event already recorded', 'status': 'success'}), 200
# Create new log entry with company_id and api_key_id from the API key
log = Log(
event_type=data['EventType'],
user_name=data['UserName'],
computer_name=data['ComputerName'],
ip_address=data['IPAddress'],
timestamp=timestamp,
retry=is_retry,
company_id=g.company_id, # Add the company ID from the API key
api_key_id=g.api_key.id # Add the API key ID
)
db.session.add(log)
db.session.commit()
return jsonify({'message': 'Event logged successfully', 'status': 'success'}), 201
except Exception as e:
db.session.rollback()
logger.exception('Failed to log event in API', extra={
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'api_key_id': getattr(g, 'api_key', {}).id if hasattr(g, 'api_key') and g.api_key else None,
'company_id': getattr(g, 'company_id', None),
'request_data': data if 'data' in locals() else None,
'timestamp_str': data.get('Timestamp') if 'data' in locals() else None,
'event_type': data.get('EventType') if 'data' in locals() else None,
'user_name': data.get('UserName') if 'data' in locals() else None,
'computer_name': data.get('ComputerName') if 'data' in locals() else None,
'retry_attempt': data.get('retry', 0) if 'data' in locals() else None,
'error': str(e)
})
return jsonify({'message': f'Failed to log event: {str(e)}', 'status': 'error'}), 500
@api.route('/health', methods=['POST'])
@require_api_key
def health_check():
"""
Health check endpoint that verifies:
- API key authentication (handled by @require_api_key decorator)
- Database connectivity
- Application status
"""
try:
# Test database connectivity by performing a simple query
# This will raise an exception if the database is not accessible
db.session.execute(text('SELECT 1')).fetchone()
# Test that we can access the API key from the decorator
api_key_id = g.api_key.id if hasattr(g, 'api_key') else None
company_id = g.company_id if hasattr(g, 'company_id') else None
# Get current timestamp in the configured timezone
app_timezone = pytz.timezone(current_app.config['TIMEZONE'])
current_time = datetime.now(app_timezone)
return jsonify({
'status': 'ok',
'message': 'Health check passed',
'timestamp': current_time.isoformat(),
'database': 'connected',
'api_key_verified': api_key_id is not None,
'company_id': company_id
}), 200
except Exception as e:
# Log the error for debugging purposes
logger.exception('Health check failed', extra={
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'api_key_id': getattr(g, 'api_key', {}).id if hasattr(g, 'api_key') and g.api_key else None,
'company_id': getattr(g, 'company_id', None),
'error': str(e)
})
return jsonify({
'status': 'error',
'message': 'Health check failed',
'error': str(e),
'database': 'disconnected'
}), 500

118
app.log Normal file
View File

@@ -0,0 +1,118 @@
nohup: ignoring input
2025-05-25 05:20:10,213 - __main__ - INFO - No specific proxy IPs configured - trusting all proxies
2025-05-25 05:20:10,229 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
2025-05-25 05:20:10,243 - app - INFO - Database logging initialized
2025-05-25 05:20:10,244 - __main__ - INFO - Starting application on 0.0.0.0:8000 with SSL
2025-05-25 05:20:10,244 - __main__ - INFO - SSL certificate: instance/certs/cert.pem
2025-05-25 05:20:10,244 - __main__ - INFO - SSL key: instance/certs/key.pem
2025-05-25 05:20:10,244 - __main__ - INFO - Development mode: False
2025-05-25 05:20:10,244 - __main__ - INFO - File watching: False
2025-05-25 05:20:10,244 - __main__ - INFO - Workers: 2
2025-05-25 05:20:10,244 - __main__ - INFO - Uvicorn allowing all IPs for forwarded headers
INFO: Uvicorn running on https://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started parent process [7718]
2025-05-25 05:20:10,568 - __mp_main__ - INFO - No specific proxy IPs configured - trusting all proxies
2025-05-25 05:20:10,571 - __mp_main__ - INFO - No specific proxy IPs configured - trusting all proxies
2025-05-25 05:20:10,586 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
2025-05-25 05:20:10,589 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
2025-05-25 05:20:10,602 - __mp_main__ - INFO - Database logging initialized
2025-05-25 05:20:10,605 - __mp_main__ - INFO - Database logging initialized
2025-05-25 05:20:10,620 - app - INFO - No specific proxy IPs configured - trusting all proxies
2025-05-25 05:20:10,623 - app - INFO - No specific proxy IPs configured - trusting all proxies
2025-05-25 05:20:10,632 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
2025-05-25 05:20:10,635 - utils.rate_limiter - INFO - No Redis URL configured, using in-memory rate limiting
2025-05-25 05:20:10,636 - app - INFO - Database logging initialized
INFO: Started server process [7721]
INFO: Waiting for application startup.
INFO: ASGI 'lifespan' protocol appears unsupported.
INFO: Application startup complete.
2025-05-25 05:20:10,639 - app - INFO - Database logging initialized
INFO: Started server process [7722]
INFO: Waiting for application startup.
INFO: ASGI 'lifespan' protocol appears unsupported.
INFO: Application startup complete.
INFO: 127.0.0.1:49078 - "GET / HTTP/1.1" 200 OK
2025-05-25 05:21:45,554 - frontend.routes - WARNING - TEST: Warning level log from /test-error-logging route
2025-05-25 05:21:45,557 - frontend.routes - ERROR - TEST: Error level log from /test-error-logging route
2025-05-25 05:21:45,559 - frontend.routes - CRITICAL - TEST: Critical level log from /test-error-logging route
2025-05-25 05:21:45,562 - app - WARNING - TEST: Flask app warning from test route
2025-05-25 05:21:45,564 - app - ERROR - TEST: Flask app error from test route
2025-05-25 05:21:45,567 - frontend.routes - ERROR - TEST: Exception caught in test route
Traceback (most recent call last):
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
raise ValueError("TEST: Intentional exception for database logging verification")
ValueError: TEST: Intentional exception for database logging verification
2025-05-25 05:21:45,569 - app - ERROR - TEST: Flask app exception in test route
Traceback (most recent call last):
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
raise ValueError("TEST: Intentional exception for database logging verification")
ValueError: TEST: Intentional exception for database logging verification
INFO: 127.0.0.1:54616 - "GET /test-error-logging HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/css/custom.css HTTP/1.1" 304 Not Modified
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
INFO: 213.249.224.235:0 - "GET /static/css/buttons.bootstrap5.min.css HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/css/buttons.dataTables.min.css HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/css/bootstrap.min.css HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/css/dataTables.bootstrap5.min.css HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/jquery-3.6.0.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/jquery.dataTables.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/bootstrap.bundle.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/buttons.html5.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/dataTables.bootstrap5.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/jszip.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/dataTables.buttons.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/js/buttons.bootstrap5.min.js HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /auth/admin HTTP/1.1" 404 Not Found
INFO: 213.249.224.235:0 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
2025-05-25 05:23:04,784 - frontend.routes - WARNING - TEST: Warning level log from /test-error-logging route
2025-05-25 05:23:04,786 - frontend.routes - ERROR - TEST: Error level log from /test-error-logging route
2025-05-25 05:23:04,787 - frontend.routes - CRITICAL - TEST: Critical level log from /test-error-logging route
2025-05-25 05:23:04,789 - app - WARNING - TEST: Flask app warning from test route
2025-05-25 05:23:04,792 - app - ERROR - TEST: Flask app error from test route
2025-05-25 05:23:04,793 - frontend.routes - ERROR - TEST: Exception caught in test route
Traceback (most recent call last):
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
raise ValueError("TEST: Intentional exception for database logging verification")
ValueError: TEST: Intentional exception for database logging verification
2025-05-25 05:23:04,795 - app - ERROR - TEST: Flask app exception in test route
Traceback (most recent call last):
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
raise ValueError("TEST: Intentional exception for database logging verification")
ValueError: TEST: Intentional exception for database logging verification
INFO: 127.0.0.1:60580 - "GET /test-error-logging HTTP/1.1" 200 OK
2025-05-25 05:24:41,331 - frontend.routes - WARNING - TEST: Warning level log from /test-error-logging route
2025-05-25 05:24:41,335 - frontend.routes - ERROR - TEST: Error level log from /test-error-logging route
2025-05-25 05:24:41,336 - frontend.routes - CRITICAL - TEST: Critical level log from /test-error-logging route
2025-05-25 05:24:41,338 - app - WARNING - TEST: Flask app warning from test route
2025-05-25 05:24:41,339 - app - ERROR - TEST: Flask app error from test route
2025-05-25 05:24:41,343 - frontend.routes - ERROR - TEST: Exception caught in test route
Traceback (most recent call last):
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
raise ValueError("TEST: Intentional exception for database logging verification")
ValueError: TEST: Intentional exception for database logging verification
2025-05-25 05:24:41,345 - app - ERROR - TEST: Flask app exception in test route
Traceback (most recent call last):
File "/opt/dockerbuilds/domain-logons/frontend/routes.py", line 42, in test_error_logging
raise ValueError("TEST: Intentional exception for database logging verification")
ValueError: TEST: Intentional exception for database logging verification
INFO: 127.0.0.1:35888 - "GET /test-error-logging HTTP/1.1" 200 OK
INFO: 127.0.0.1:35904 - "POST /api/log_event HTTP/1.1" 401 Unauthorized
INFO: 213.249.224.235:0 - "GET /auth/admin/error_logs?start_date=2025-05-18&end_date=2025-05-25&level= HTTP/1.1" 200 OK
INFO: 213.249.224.235:0 - "GET /static/img/favicon.png HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:41952 - "GET / HTTP/1.1" 200 OK
INFO: Received SIGTERM, exiting.
INFO: Terminated child process [7721]
INFO: Terminated child process [7722]
INFO: Waiting for child process [7721]
INFO: Shutting down
INFO: Shutting down
INFO: Finished server process [7721]
INFO: Finished server process [7722]
INFO: Waiting for child process [7722]
INFO: Stopping parent process [7718]

365
app.py Normal file
View File

@@ -0,0 +1,365 @@
from flask import Flask, session, request, send_from_directory, render_template
from asgiref.wsgi import WsgiToAsgi
from extensions import db, bcrypt, login_manager, get_env_var
from auth.models import User, Settings, ApiKey
import ssl
import logging
from flask_wtf import CSRFProtect
from auth import auth_bp
from api import api_bp
from frontend import frontend_bp
from datetime import datetime, timezone, timedelta
import pytz
import configparser
import os
import uvicorn
import argparse
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.exceptions import HTTPException
from flask_compress import Compress
# Import security utilities
from utils.security_headers import setup_security_headers
from utils.rate_limiter import apply_rate_limits
from utils.db_logging import setup_database_logging
# Removed SQLite encryption import
# Load configuration from ini file
config = configparser.ConfigParser()
config_file = os.path.join(os.path.dirname(__file__), 'config.ini')
config.read(config_file)
# Set up logging
logging.basicConfig(
level=logging.DEBUG if config.getboolean('app', 'APP_DEBUG', fallback=True) else logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# Configure WSGI middleware for reverse proxy support (Traefik)
proxy_count = config.getint('proxy', 'PROXY_COUNT', fallback=1)
trust_x_forwarded_for = config.getboolean('proxy', 'TRUST_X_FORWARDED_FOR', fallback=True)
trust_x_forwarded_proto = config.getboolean('proxy', 'TRUST_X_FORWARDED_PROTO', fallback=True)
trust_x_forwarded_host = config.getboolean('proxy', 'TRUST_X_FORWARDED_HOST', fallback=True)
trust_x_forwarded_port = config.getboolean('proxy', 'TRUST_X_FORWARDED_PORT', fallback=True)
trust_x_forwarded_prefix = config.getboolean('proxy', 'TRUST_X_FORWARDED_PREFIX', fallback=False)
# Get trusted proxy IPs
trusted_proxies = config.get('proxy', 'TRUSTED_PROXIES', fallback='').strip()
if trusted_proxies:
# Parse comma-separated IPs/CIDRs
trusted_proxy_list = [ip.strip() for ip in trusted_proxies.split(',') if ip.strip()]
logger.info(f"Configured trusted proxies: {trusted_proxy_list}")
else:
trusted_proxy_list = None
logger.info("No specific proxy IPs configured - trusting all proxies")
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=proxy_count if trust_x_forwarded_for else 0,
x_proto=proxy_count if trust_x_forwarded_proto else 0,
x_host=proxy_count if trust_x_forwarded_host else 0,
x_port=proxy_count if trust_x_forwarded_port else 0,
x_prefix=proxy_count if trust_x_forwarded_prefix else 0
)
# Configure Flask app from environment variables with fallback to config file
app.config['SECRET_KEY'] = get_env_var('SECRET_KEY', config.get('app', 'SECRET_KEY', fallback='your_secret_key'))
app.config['SQLALCHEMY_DATABASE_URI'] = get_env_var('DATABASE_URL', config.get('database', 'SQLALCHEMY_DATABASE_URI', fallback='sqlite:///database.db'))
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.getboolean('database', 'SQLALCHEMY_TRACK_MODIFICATIONS', fallback=False)
# Session security settings from environment or config
app.config['SESSION_COOKIE_SECURE'] = get_env_var('SESSION_COOKIE_SECURE', config.getboolean('session', 'SESSION_COOKIE_SECURE', fallback=True))
app.config['SESSION_COOKIE_HTTPONLY'] = get_env_var('SESSION_COOKIE_HTTPONLY', config.getboolean('session', 'SESSION_COOKIE_HTTPONLY', fallback=True))
app.config['SESSION_COOKIE_SAMESITE'] = get_env_var('SESSION_COOKIE_SAMESITE', config.get('session', 'SESSION_COOKIE_SAMESITE', fallback='Lax'))
app.config['REMEMBER_COOKIE_SECURE'] = get_env_var('REMEMBER_COOKIE_SECURE', config.getboolean('session', 'REMEMBER_COOKIE_SECURE', fallback=True))
app.config['REMEMBER_COOKIE_HTTPONLY'] = get_env_var('REMEMBER_COOKIE_HTTPONLY', config.getboolean('session', 'REMEMBER_COOKIE_HTTPONLY', fallback=True))
app.config['REMEMBER_COOKIE_DURATION'] = int(get_env_var('REMEMBER_COOKIE_DURATION', config.getint('session', 'REMEMBER_COOKIE_DURATION', fallback=7200)))
app.config['PERMANENT_SESSION_LIFETIME'] = int(get_env_var('PERMANENT_SESSION_LIFETIME', config.getint('session', 'PERMANENT_SESSION_LIFETIME', fallback=7200)))
app.config['APP_DEBUG'] = get_env_var('APP_DEBUG', config.getboolean('app', 'APP_DEBUG', fallback=False))
app.config['TIMEZONE'] = get_env_var('TIMEZONE', config.get('app', 'TIMEZONE', fallback='Europe/London'))
# Setup compression if enabled
if config.getboolean('cache', 'ENABLE_COMPRESSION', fallback=True):
compress = Compress()
compress.init_app(app)
# Configure compression level and threshold
app.config['COMPRESS_LEVEL'] = config.getint('cache', 'COMPRESSION_LEVEL', fallback=6)
app.config['COMPRESS_MIN_SIZE'] = config.getint('cache', 'COMPRESSION_MIN_SIZE', fallback=500)
app.config['COMPRESS_MIMETYPES'] = [
'text/html', 'text/css', 'text/xml', 'application/json', 'application/javascript',
'application/x-javascript', 'image/svg+xml'
]
# Enable CSRF protection
csrf = CSRFProtect(app)
# Configure static files with caching
@app.route('/favicon.ico')
def favicon():
response = send_from_directory(os.path.join(app.root_path, 'static', 'img'),
'favicon.ico', mimetype='image/ico')
# Add cache headers manually instead of using cache_timeout
max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
response.headers['Cache-Control'] = f'public, max-age={max_age}'
response.headers['Expires'] = (datetime.now(timezone.utc) + timedelta(seconds=max_age)).strftime('%a, %d %b %Y %H:%M:%S GMT')
return response
# Setup security headers
setup_security_headers(app, config)
# Configure HSTS settings for security headers
app.config['HSTS_ENABLED'] = config.getboolean('security', 'ENABLE_HSTS', fallback=True)
app.config['HSTS_MAX_AGE'] = config.getint('security', 'HSTS_MAX_AGE', fallback=31536000)
# return send_from_directory(os.path.join(app.root_path, 'static', 'img'),
# 'favicon.png', mimetype='image/png',
# cache_timeout=config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800))
# Add cache headers to static files
@app.after_request
def add_cache_headers(response):
# Only add cache headers for static files
if request.path.startswith('/static/'):
# Get cache settings from config
default_max_age = config.getint('cache', 'STATIC_MAX_AGE', fallback=86400)
image_max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
js_css_max_age = config.getint('cache', 'JS_CSS_MAX_AGE', fallback=43200)
# Set default cache expiration
max_age = default_max_age
# Set longer cache for assets that rarely change like fonts, images
if any(request.path.endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff2', '.woff', '.ttf']):
max_age = image_max_age
# Set shorter cache for JS and CSS that might change with deployments
if any(request.path.endswith(ext) for ext in ['.js', '.css']):
max_age = js_css_max_age
response.headers['Cache-Control'] = f'public, max-age={max_age}'
response.headers['Expires'] = (datetime.now(timezone.utc) + timedelta(seconds=max_age)).strftime('%a, %d %b %Y %H:%M:%S GMT')
# Add ETag support for efficient caching
if 'ETag' not in response.headers:
response.add_etag()
return response
# Request logger middleware
@app.before_request
def log_request_info():
if app.config['APP_DEBUG']:
logger.debug('Request URL: %s', request.url)
logger.debug('Request Method: %s', request.method)
logger.debug('Request Headers: %s', dict(request.headers))
# Only try to access request.json if the content type is application/json
if request.is_json and request.get_data(as_text=True):
try:
logger.debug('Request Body: %s', request.json)
except Exception as e:
logger.debug('Error parsing JSON: %s', str(e))
# Ensure session is permanent and uses configured lifetime
@app.before_request
def make_session_permanent():
session.permanent = True
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
wsg = WsgiToAsgi(app)
# Register blueprints
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(frontend_bp)
# Setup rate limiting after blueprints are registered
apply_rate_limits(app, config)
def handle_http_exception(exc:HTTPException):
"""Use the code and description from an HTTPException to inform the user of an error"""
logger.debug('HTTP error %s - %s', exc.code, exc.description)
return render_template("error.html", status_code=exc.code, description=exc.description)
def handle_uncaught_exception(exc:Exception):
"""Log the exception, then return a generic server error page."""
logger.warning('HTTP error 500 - Internal server error')
return render_template("error.html", status_code=500, description='Internal server error')
# This handler is run when an HTTPException, or any of its subclasses, is raised
app.register_error_handler(HTTPException, handle_http_exception)
# This handler is run for all other uncaught exceptions
app.register_error_handler(Exception, handle_uncaught_exception)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
def init_app():
with app.app_context():
db.create_all()
# Create settings if not exists
if not Settings.query.first():
settings = Settings(
allow_registration=False,
restrict_email_domains=False,
log_level='WARNING' # Default logging level
)
db.session.add(settings)
else:
# Update existing settings to have log_level if it doesn't exist
settings = Settings.query.first()
if not hasattr(settings, 'log_level') or settings.log_level is None:
settings.log_level = 'WARNING'
db.session.add(settings)
# Create default admin if not exists
admin = User.query.filter_by(email='superadmin@example.com').first()
if not admin:
hashed_password = bcrypt.generate_password_hash('adminsuper').decode('utf-8')
admin = User(
username='superadmin',
email='superadmin@example.com',
password=hashed_password,
role='GlobalAdmin',
is_active=True
)
db.session.add(admin)
db.session.commit()
# Create initial API key for admin
api_key = ApiKey(
key=ApiKey.generate_key(),
description="Initial Admin API Key",
user_id=admin.id
)
db.session.add(api_key)
db.session.commit()
# Initialize the application
init_app()
# Setup database logging after app initialization
setup_database_logging(app)
@app.context_processor
def inject_settings():
settings = Settings.query.first()
return dict(allow_registration=settings.allow_registration if settings else False)
# Add template filter for proper timezone display
@app.template_filter('format_datetime')
def format_datetime(value):
"""Format a datetime with proper timezone"""
if value is None:
return ""
# Use the timezone utility function
from utils.toolbox import format_timestamp_for_display
return format_timestamp_for_display(value)
# Exempt API endpoints from CSRF (since they use API keys)
with app.app_context():
csrf.exempt(api_bp)
def run_app():
"""Start the application with Uvicorn using config settings"""
host = config.get('server', 'HOST', fallback='0.0.0.0')
port = config.getint('server', 'PORT', fallback=8000)
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
# Get new configuration settings
development_mode = config.getboolean('server', 'DEVELOPMENT_MODE', fallback=False)
watch_files = config.getboolean('server', 'WATCH_FILES', fallback=False)
workers_setting = config.get('server', 'WORKERS', fallback='1')
worker_lifetime = config.getint('server', 'WORKER_LIFETIME', fallback=86400)
graceful_shutdown = config.getboolean('server', 'GRACEFUL_SHUTDOWN', fallback=True)
shutdown_timeout = config.getint('server', 'SHUTDOWN_TIMEOUT', fallback=30)
# Parse workers setting - could be "auto" or a number
workers = None
if workers_setting.lower() == 'auto':
import multiprocessing
workers = multiprocessing.cpu_count()
else:
try:
workers = int(workers_setting)
except ValueError:
logger.warning(f"Invalid WORKERS setting '{workers_setting}', defaulting to 1")
workers = 1
# Only enable file watching in development mode
reload_enabled = development_mode and watch_files
# Use debug log level in development mode
log_level = "debug" if development_mode else "info"
logger.info(f"Starting application on {host}:{port} with SSL")
logger.info(f"SSL certificate: {ssl_certfile}")
logger.info(f"SSL key: {ssl_keyfile}")
logger.info(f"Development mode: {development_mode}")
logger.info(f"File watching: {reload_enabled}")
logger.info(f"Workers: {workers}")
# Get max requests per worker before graceful restart
# Setting to None disables the worker auto-restart feature
limit_max_requests = None
if worker_lifetime > 0:
# If worker_lifetime is set (> 0), we'll use a reasonable request limit
# Default to around 10,000 requests per worker before restart
limit_max_requests = 10000
# Get trusted proxy configuration for Uvicorn
trusted_proxies_config = config.get('proxy', 'TRUSTED_PROXIES', fallback='').strip()
if trusted_proxies_config:
forwarded_allow_ips = trusted_proxies_config.replace(',', ' ')
logger.info(f"Uvicorn forwarded_allow_ips: {forwarded_allow_ips}")
else:
forwarded_allow_ips = '*'
logger.info("Uvicorn allowing all IPs for forwarded headers")
uvicorn.run(
"app:wsg",
host=host,
port=port,
reload=reload_enabled,
workers=workers,
log_level=log_level,
ssl_certfile=ssl_certfile,
ssl_keyfile=ssl_keyfile,
proxy_headers=True,
forwarded_allow_ips=forwarded_allow_ips,
timeout_keep_alive=65, # Keep-alive timeout to detect hanging connections
limit_max_requests=limit_max_requests, # Fixed: Only restart workers after this many requests
timeout_graceful_shutdown=shutdown_timeout
)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Domain Logons Monitoring Application')
parser.add_argument('--legacy', action='store_true', help='Use legacy Flask server instead of Uvicorn')
args = parser.parse_args()
if args.legacy:
# Legacy Flask server mode
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
ssl_context.verify_mode = ssl.CERT_NONE # Accept self-signed certificates
host = config.get('server', 'HOST', fallback='0.0.0.0')
port = config.getint('server', 'PORT', fallback=8000)
app.run(debug=app.config['APP_DEBUG'], ssl_context=ssl_context, host=host, port=port)
else:
# Default to Uvicorn server
run_app()

360
app_info.md Normal file
View File

@@ -0,0 +1,360 @@
# Domain Login Monitoring System - Application Information
## **Overview**
A login monitoring system built with Flask that tracks user authentication events across Windows domains. Features multi-tenancy support.
## **Architecture**
### **Core Components**
```
domain-logons/
├── app.py # Main Flask application with Uvicorn ASGI server
├── extensions.py # Flask extensions and configuration
├── config.ini # Configuration file with multiple database support
├── requirements.txt # Python dependencies
└── run.sh # Application startup script
├── auth/ # Authentication & Authorization Module
│ ├── models.py # User, Company, ApiKey, Settings models
│ ├── routes.py # Auth routes (login, register, MFA, admin)
│ └── forms.py # WTForms for validation
├── api/ # REST API Module
│ ├── models.py # Log, ErrorLog models
│ └── routes.py # API endpoints (/log_event, /health)
├── frontend/ # Web Interface Module
│ ├── routes.py # Dashboard, reports, home routes
│ └── models.py # Frontend-specific models
├── utils/ # Utility Modules
│ ├── security_headers.py # Security headers middleware
│ ├── rate_limiter.py # Rate limiting with Redis support
│ └── health_check.py # System health monitoring
├── templates/ # Jinja2 Templates
│ ├── base.html # Base template with dark theme
│ ├── auth/ # Authentication templates
│ └── frontend/ # Dashboard and report templates
├── static/ # Static Assets
│ ├── css/ # Bootstrap 5, DataTables, custom CSS
│ ├── js/ # jQuery, DataTables, charts, custom JS
│ └── img/ # Icons and images
├── windows_agent/ # Windows Client
│ └── winagentUSM.exe # Compiled Windows monitoring agent
└── instance/ # Instance-specific files
├── database.db # SQLite database (default)
└── certs/ # SSL certificates
```
## 🔧 **Technology Stack**
### **Backend Framework**
- **Flask**: Web framework with blueprint architecture
- **Uvicorn**: ASGI server for production deployment
- **SQLAlchemy**: ORM with support for SQLite, PostgreSQL, MySQL, MSSQL
- **Flask-Login**: Session management and authentication
- **Flask-WTF**: Form handling and CSRF protection
### **Security & Authentication**
- **Bcrypt**: Password hashing
- **PyOTP**: Time-based One-Time Password (TOTP) for MFA
- **JWT**: Optional token-based authentication
- **CSRF Protection**: Built-in token validation
- **Security Headers**: CSP, HSTS, X-Frame-Options
- **Rate Limiting**: IP-based with Redis backend support
### **Frontend**
- **Bootstrap 5**: Responsive UI framework with dark theme
- **DataTables**: Advanced table features with export capabilities
- **Chart.js**: Data visualization for reports
- **Moment.js**: Date/time handling
- **DateRangePicker**: Advanced date selection
### **Database Support**
- **SQLite**: Default (file-based)
- **PostgreSQL**: Production recommended
- **MySQL/MariaDB**: Enterprise support
- **Microsoft SQL Server**: Corporate environments
### **Deployment**
- **Docker**: Containerization ready
- **Traefik**: Reverse proxy with SSL termination
- **systemd**: Linux service integration
- **SSL/TLS**: Built-in HTTPS support
## **Key Features**
### **Multi-Tenancy**
- **Company Isolation**: Complete data separation between organizations
- **Role-Based Access Control**: GlobalAdmin, Admin, CompanyAdmin, User roles
- **API Key Management**: Per-company API keys with usage tracking
- **User Assignment**: Users can belong to multiple companies
### **Authentication & Security**
- **Multi-Factor Authentication (MFA)**: TOTP with QR code setup
- **Flexible MFA Policies**: Global enforcement with per-user overrides
- **Password Policies**: Configurable strength requirements
- **Session Security**: Secure cookies, HTTPS enforcement
- **Rate Limiting**: Brute force protection
### **Monitoring & Logging**
- **Real-time Event Tracking**: Login, logout, lock events
- **Windows Agent Integration**: Automated event collection
- **API Health Monitoring**: Database connectivity checks
- **Error Logging**: Structured application error tracking
- **Audit Trail**: Complete user action logging
### **Dashboard & Reporting**
- **Interactive Dashboard**: Real-time login event monitoring
- **Column Visibility Controls**: Customizable table views with localStorage persistence
- **Time Spent Reports**: User session duration analysis
- **Export Capabilities**: CSV, Excel, PDF export
- **Date Range Filtering**: Flexible time period selection
### **API Capabilities**
- **RESTful API**: JSON-based event logging
- **API Key Authentication**: Secure programmatic access
- **Health Check Endpoint**: System status monitoring
- **Timestamp Flexibility**: Multiple format support
### **Core Tables**
```sql
-- Authentication
app_auth_users # User accounts with MFA
app_auth_companies # Company/organization entities
app_auth_user_companies # Many-to-many user-company relationships
app_auth_api_keys # API keys with company association
app_auth_settings # Global application settings
-- Logging
api_logs # Login/logout event records
api_error_logs # Application error tracking
```
### **Key Relationships**
- Users ↔ Companies (Many-to-Many via UserCompany)
- Companies → API Keys (One-to-Many)
- API Keys → Logs (One-to-Many)
- Users → API Keys (One-to-Many)
### **Authentication Security**
- Bcrypt password hashing with configurable rounds
- TOTP-based MFA
- Session timeout and secure cookie settings
- CSRF protection on all forms
- Password strength validation
### **Network Security**
- HTTPS enforcement with HSTS
- Security headers (CSP, X-Frame-Options, etc.)
- Proxy support for Traefik/nginx
- IP-based rate limiting
- Trusted proxy configuration
### **Application Security**
- SQL injection prevention via ORM
- XSS protection with template escaping
- Input validation and sanitization
- Error handling without information disclosure
### **Dashboard Features**
- Real-time login event display
- Advanced filtering and search
- Column visibility customization
- Export functionality (CSV, Excel, Print)
- Responsive design for mobile devices
### **Reporting Capabilities**
- Time spent analysis per user
- Login frequency reports
- Failed authentication tracking
- Company-specific analytics
### **System Monitoring**
- Database connectivity health checks
## 🛠️ **Configuration**
### **Environment Variables**
```bash
# Security
SECRET_KEY=your-secret-key-here
SESSION_COOKIE_SECURE=true
# Database
DATABASE_URL=sqlite:///database.db
# or: postgresql://user:pass@host:port/db
# or: mysql+pymysql://user:pass@host:port/db
# Application
APP_DEBUG=false
TIMEZONE=Europe/London
# Server
HOST=0.0.0.0
PORT=8000
SSL_CERTFILE=certs/cert.pem
SSL_KEYFILE=certs/key.pem
```
### **Configuration File Structure**
```ini
[app] # Application settings
[database] # Database connection
[server] # Server configuration
[session] # Session security
[security] # Security headers
[cache] # Static file caching
[proxy] # Reverse proxy settings
[rate_limiting] # Rate limit configuration
```
## **Deployment Options**
### **Development**
```bash
# Install dependencies
pip install -r requirements.txt
# Generate SSL certificates
openssl req -x509 -newkey rsa:4096 -keyout certs/key.pem -out certs/cert.pem -days 3650 -nodes
# Run application
python app.py
```
### **Production with Docker**
```dockerfile
FROM python:3.11-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
```
### **Systemd Service**
```ini
[Unit]
Description=Domain Login Monitor
After=network.target
[Service]
Type=exec
User=www-data
WorkingDirectory=/opt/domain-logons
ExecStart=/opt/domain-logons/.venv/bin/python app.py
Restart=always
[Install]
WantedBy=multi-user.target
```
## 📡 **API Reference**
### **Authentication**
All API endpoints require `X-API-Key` header with valid API key.
### **Endpoints**
#### **POST /api/log_event**
Log authentication events from Windows clients.
**Request:**
```json
{
"EventType": "Sign-In|Sign-Out|Lock",
"UserName": "john.doe",
"ComputerName": "WORKSTATION-01",
"IPAddress": "192.168.1.100",
"Timestamp": "2025-05-25T14:30:00Z",
"retry": 0
}
```
**Response:**
```json
{
"message": "Event logged successfully",
"status": "success"
}
```
#### **POST /api/health**
Check system health and API connectivity.
**Response:**
```json
{
"status": "ok",
"message": "Health check passed",
"timestamp": "2025-05-25T14:30:00+00:00",
"database": "connected",
"api_key_verified": true,
"company_id": 1
}
```
## **Windows Client Integration**
### **Compiled Agent**
- **winagentUSM.exe**: Standalone Windows executable
- **Event Log Integration**: Monitors Windows Security events
- **Automatic Retry**: Built-in error handling and retry logic
- **Service Mode**: Can run as Windows service
- It is build with GO Lang
## **Default Credentials**
### **Initial Admin Account**
- **Username**: `superadmin`
- **Email**: `superadmin@example.com`
- **Password**: `adminsuper`
- **Role**: `GlobalAdmin`
### **API Key**
Initial API key is automatically generated for the admin account and displayed in Admin Settings.
## **Database Migration Support**
### **Supported Databases**
- **SQLite**: Default, perfect for small-medium deployments
- **PostgreSQL**: Recommended for production (best performance)
- **MySQL/MariaDB**: Enterprise environments
- **Microsoft SQL Server**: Corporate Windows environments
### **Migration Path**
1. Export data from current database
2. Update `config.ini` with new database connection
3. Run application to auto-create tables
4. Import data using provided migration scripts
## **Testing & Validation**
### **API Testing**
```bash
# Test login event
curl -k -X POST https://localhost:8000/api/log_event \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"EventType": "Sign-In", "UserName": "test", "ComputerName": "TEST-PC", "IPAddress": "192.168.1.100", "Timestamp": "2025-05-25T14:30:00Z"}'
# Test health check
curl -k -X POST https://localhost:8000/api/health \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY"
```
### **Web Interface Testing**
1. Navigate to `https://localhost:8000`
2. Login with default credentials
3. Create companies and users
4. Generate API keys
5. Test dashboard functionality
### **Additional Resources**
- **Configuration Reference**: `config.ini`

1
auth/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import auth_bp

80
auth/forms.py Normal file
View File

@@ -0,0 +1,80 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, BooleanField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from email_validator import validate_email, EmailNotValidError
from .models import User, Settings, AllowedDomain
from extensions import db
import re
def validate_password_strength(password):
"""Validate password based on current settings"""
settings = Settings.query.first()
if not settings:
return # No settings found, allow any password
errors = []
# Check minimum length
if len(password) < settings.password_min_length:
errors.append(f'Password must be at least {settings.password_min_length} characters long.')
# Check for numbers and mixed case if required
if settings.password_require_numbers_mixed_case:
if not re.search(r'[0-9]', password):
errors.append('Password must contain at least one number.')
if not re.search(r'[a-z]', password):
errors.append('Password must contain at least one lowercase letter.')
if not re.search(r'[A-Z]', password):
errors.append('Password must contain at least one uppercase letter.')
# Check for special characters if required
if settings.password_require_special_chars:
safe_chars = settings.password_safe_special_chars or '!@#$%^&*()_+-=[]{}|;:,.<>?'
if not any(char in safe_chars for char in password):
errors.append(f'Password must contain at least one special character from: {safe_chars}')
if errors:
raise ValidationError(' '.join(errors))
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), validate_password_strength])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Sign Up')
def validate_username(self, username):
user = User.query.filter(db.func.lower(User.username) == username.data.lower()).first()
if user:
raise ValidationError('That username is taken. Please choose a different one.')
def validate_email(self, email):
try:
validate_email(email.data)
except EmailNotValidError:
raise ValidationError('Invalid email address.')
settings = Settings.query.first()
if settings and settings.restrict_email_domains:
domain = '@' + email.data.split('@')[1].lower()
if not AllowedDomain.query.filter_by(domain=domain).first():
raise ValidationError('Registration is not allowed for this email domain.')
user = User.query.filter(db.func.lower(User.email) == email.data.lower()).first()
if user:
raise ValidationError('That email is taken. Please choose a different one.')
class LoginForm(FlaskForm):
email = StringField('Username or Email', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
remember = BooleanField('Remember Me')
submit = SubmitField('Login')
class ChangePasswordForm(FlaskForm):
current_password = PasswordField('Current Password', validators=[DataRequired()])
new_password = PasswordField('New Password', validators=[DataRequired(), validate_password_strength])
confirm_password = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('new_password')])
submit = SubmitField('Change Password')
class ApiKeyForm(FlaskForm):
submit = SubmitField('Generate New API Key')

132
auth/models.py Normal file
View File

@@ -0,0 +1,132 @@
from flask_login import UserMixin
from extensions import db
from datetime import datetime
import secrets
import pyotp
class Settings(db.Model):
__tablename__ = 'app_auth_settings'
id = db.Column(db.Integer, primary_key=True)
allow_registration = db.Column(db.Boolean, default=False)
restrict_email_domains = db.Column(db.Boolean, default=False)
# Password strength requirements
password_min_length = db.Column(db.Integer, default=10)
password_require_numbers_mixed_case = db.Column(db.Boolean, default=True)
password_require_special_chars = db.Column(db.Boolean, default=True)
password_safe_special_chars = db.Column(db.String(100), default='!@#$%^&*()_+-=[]{}|;:,.<>?')
# MFA requirements
require_mfa_for_all_users = db.Column(db.Boolean, default=False)
# Database logging configuration
log_level = db.Column(db.String(20), default='WARNING')
# New Company model
class Company(db.Model):
__tablename__ = 'app_auth_companies'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.String(200))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
users = db.relationship('UserCompany', back_populates='company')
api_keys = db.relationship('ApiKey', backref='company', lazy=True)
# User-Company association table
class UserCompany(db.Model):
__tablename__ = 'app_auth_user_companies'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('app_auth_users.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=False)
role = db.Column(db.String(50), nullable=False, default='User') # Role specific to this company: 'User', 'CompanyAdmin'
# Relationships
user = db.relationship('User', back_populates='companies')
company = db.relationship('Company', back_populates='users')
class User(db.Model, UserMixin):
__tablename__ = 'app_auth_users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(60), nullable=False)
role = db.Column(db.String(20), nullable=False, default='User') # Global role: 'User', 'Admin', 'GlobalAdmin'
is_active = db.Column(db.Boolean, default=False)
mfa_secret = db.Column(db.String(32))
mfa_enabled = db.Column(db.Boolean, default=False)
mfa_required = db.Column(db.Boolean, default=None) # None=inherit from global, True=required, False=not required
api_keys = db.relationship('ApiKey', backref='user', lazy=True)
# User-company relationship
companies = db.relationship('UserCompany', back_populates='user')
def get_mfa_uri(self):
if self.mfa_secret:
return pyotp.totp.TOTP(self.mfa_secret).provisioning_uri(
name=self.email,
issuer_name="Domain Logon Monitor"
)
return None
def verify_mfa_code(self, code):
if not self.mfa_secret or not code:
return False
totp = pyotp.TOTP(self.mfa_secret)
return totp.verify(code)
def is_mfa_required(self):
"""Check if MFA is required for this user based on global and per-user settings"""
# GlobalAdmin accounts are exempt when global setting is ON
if self.role == 'GlobalAdmin':
return False
# Check per-user setting first
if self.mfa_required is not None:
return self.mfa_required
# Fall back to global setting
settings = Settings.query.first()
return settings.require_mfa_for_all_users if settings else False
def generate_mfa_secret(self):
self.mfa_secret = pyotp.random_base32()
return self.mfa_secret
def is_company_admin(self, company_id):
"""Check if user is an admin for a specific company"""
for uc in self.companies:
if uc.company_id == company_id and uc.role == 'CompanyAdmin':
return True
return False
def is_global_admin(self):
"""Check if user is a global administrator"""
return self.role == 'GlobalAdmin'
def is_admin(self):
"""Check if user is an admin (but not global admin)"""
return self.role == 'Admin'
def get_companies(self):
"""Get all companies this user has access to"""
return [uc.company for uc in self.companies]
class ApiKey(db.Model):
__tablename__ = 'app_auth_api_keys'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(64), unique=True, nullable=False)
description = db.Column(db.String(100))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
last_used = db.Column(db.DateTime)
is_active = db.Column(db.Boolean, default=True) # New field to control API key status
user_id = db.Column(db.Integer, db.ForeignKey('app_auth_users.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=True)
@staticmethod
def generate_key():
return secrets.token_hex(32)
class AllowedDomain(db.Model):
__tablename__ = 'app_auth_allowed_domains'
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(100), unique=True, nullable=False)

1937
auth/routes.py Normal file

File diff suppressed because it is too large Load Diff

164
config.ini Normal file
View File

@@ -0,0 +1,164 @@
[app]
SECRET_KEY = your_secret_key
APP_DEBUG = true
TIMEZONE = Europe/London
[server]
HOST = 0.0.0.0
PORT = 8000
SSL_CERTFILE = instance/certs/cert.pem
SSL_KEYFILE = instance/certs/key.pem
; Server configuration
; DEVELOPMENT_MODE: When true, enables development features (default: false)
DEVELOPMENT_MODE = true
; Watch for file changes and reload automatically (development only, default: false)
WATCH_FILES = true
; Number of worker processes for Uvicorn (default: 1)
; For production, set to 2-4 workers for most servers
; "auto" uses CPU count but may be excessive for some systems
WORKERS = 2
; Maximum number of seconds a worker can live (helps with memory leaks)
WORKER_LIFETIME = 86400
; Determines if server should stop gracefully or immediately on receiving SIGINT/SIGTERM
GRACEFUL_SHUTDOWN = true
; Timeout in seconds for graceful shutdown (default: 30)
SHUTDOWN_TIMEOUT = 30
[database]
; Current SQLite configuration
SQLALCHEMY_DATABASE_URI = sqlite:///database.db
SQLALCHEMY_TRACK_MODIFICATIONS = false
; ====== DATABASE CONNECTION EXAMPLES ======
; Uncomment one of these examples and comment out the SQLite connection above to switch databases
; === PostgreSQL Example ===
; Setup:
; 1. Install PostgreSQL server
; 2. Create database and user with proper permissions
; 3. Install Python driver: pip install psycopg2-binary
;
; SQLALCHEMY_DATABASE_URI = postgresql://username:password@localhost:5432/database_name
; For SSL connection:
; SQLALCHEMY_DATABASE_URI = postgresql://username:password@localhost:5432/database_name?sslmode=require
; === MySQL/MariaDB Example ===
; Setup:
; 1. Install MySQL/MariaDB server
; 2. Create database and user with proper permissions
; 3. Install Python driver: pip install pymysql
;
; SQLALCHEMY_DATABASE_URI = mysql+pymysql://username:password@localhost:3306/database_name
; For SSL connection:
; SQLALCHEMY_DATABASE_URI = mysql+pymysql://username:password@localhost:3306/database_name?ssl_ca=/path/to/ca.pem
; === MSSQL Server Example ===
; Setup:
; 1. Install MSSQL Server
; 2. Create database and user
; 3. Install Python driver: pip install pyodbc
; 4. Install ODBC Driver for SQL Server:
; - On Ubuntu/Debian:
; sudo apt-get install -y unixodbc-dev
; sudo curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
; sudo curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list > /etc/apt/sources.list.d/mssql-release.list
; sudo apt-get update
; sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 # Driver 18 (latest)
; # Or for older driver: sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17
; - On RHEL/CentOS:
; sudo curl https://packages.microsoft.com/config/rhel/8/prod.repo > /etc/yum.repos.d/mssql-release.repo
; sudo ACCEPT_EULA=Y dnf install -y msodbcsql18 # Driver 18 (latest)
; # Or for older driver: sudo ACCEPT_EULA=Y dnf install -y msodbcsql17
; - On Windows:
; Download and install from https://go.microsoft.com/fwlink/?linkid=2249006 # Driver 18
; # Or for older driver: https://go.microsoft.com/fwlink/?linkid=2187217 # Driver 17
;
; # Using ODBC Driver 18 (recommended)
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name/database_name?driver=ODBC+Driver+18+for+SQL+Server
; # Using ODBC Driver 17
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name/database_name?driver=ODBC+Driver+17+for+SQL+Server
; # For named instance:
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name\\instance_name/database_name?driver=ODBC+Driver+18+for+SQL+Server
[session]
SESSION_COOKIE_SECURE = true
SESSION_COOKIE_HTTPONLY = true
SESSION_COOKIE_SAMESITE = Lax
REMEMBER_COOKIE_SECURE = true
REMEMBER_COOKIE_HTTPONLY = true
REMEMBER_COOKIE_DURATION = 7200
PERMANENT_SESSION_LIFETIME = 7200
[cache]
STATIC_MAX_AGE = 86400
IMAGE_MAX_AGE = 604800
JS_CSS_MAX_AGE = 43200
ENABLE_COMPRESSION = true
COMPRESSION_LEVEL = 6
COMPRESSION_MIN_SIZE = 500
[security]
; Security headers configuration
CONTENT_SECURITY_POLICY = default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'
ENABLE_HSTS = true
HSTS_MAX_AGE = 31536000
ENABLE_SECURITY_HEADERS = true
[rate_limiting]
; Rate limiting configuration
ENABLE_RATE_LIMITING = true
; Redis connection for rate limiting (leave empty to use in-memory storage)
; REDIS_URL = redis://localhost:6379/0
REDIS_URL =
; Login endpoint limits
LOGIN_LIMIT = 10
LOGIN_PERIOD = 60
; Registration endpoint limits
REGISTER_LIMIT = 5
REGISTER_PERIOD = 300
; API endpoint limits
API_LIMIT = 60
API_PERIOD = 60
[proxy]
; Reverse proxy configuration for Traefik
; Number of proxies between the client and your app (default: 1 for single proxy like Traefik)
PROXY_COUNT = 1
; Whether to trust X-Forwarded-For header (required for Traefik)
TRUST_X_FORWARDED_FOR = true
; Whether to trust X-Forwarded-Proto header (for HTTPS detection)
TRUST_X_FORWARDED_PROTO = true
; Whether to trust X-Forwarded-Host header
TRUST_X_FORWARDED_HOST = true
; Whether to trust X-Forwarded-Port header
TRUST_X_FORWARDED_PORT = true
; Whether to trust X-Forwarded-Prefix header
TRUST_X_FORWARDED_PREFIX = false
; Trusted proxy IPs (leave empty to trust all, comma-separated for multiple)
; For production with Traefik, specify your Traefik container IP or Docker network CIDR
; Examples:
; TRUSTED_PROXIES = 172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 # Docker default networks
; TRUSTED_PROXIES = 172.20.0.2,172.20.0.3 # Specific Traefik IPs
; TRUSTED_PROXIES = 172.18.0.0/16 # Custom Docker network
; For development/testing, leave empty to trust all proxies:
TRUSTED_PROXIES =
[logging]
; Database logging configuration
; Enable/disable database logging entirely
DB_LOGGING_ENABLED = true
; Loggers to exclude from database logging (comma-separated)
; These loggers often create feedback loops or excessive noise
DB_LOGGING_FILTERED_LOGGERS = watchfiles.main,watchfiles.watcher,watchdog,uvicorn.access,__mp_main__,__main__,app
; Message patterns to exclude from database logging (comma-separated)
; Messages containing these patterns will not be logged to database
DB_LOGGING_FILTERED_PATTERNS = database.db,instance/,file changed,reloading
; Enable filtering of file watcher logs (prevents feedback loops in debug mode)
FILTER_FILE_WATCHER_LOGS = true
; Minimum time between identical log entries (seconds) to prevent spam
DB_LOGGING_DEDUPE_INTERVAL = 1

28
extensions.py Normal file
View File

@@ -0,0 +1,28 @@
import os
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
from flask import Blueprint
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Initialize extensions
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()
login_manager.login_view = 'auth.login' # Updated to use blueprint route
login_manager.login_message_category = 'info'
# Environment variable helpers
def get_env_var(name, default=None):
"""Get environment variable with logging for missing critical values"""
value = os.environ.get(name, default)
if value is None:
logger.warning(f"Environment variable {name} not set!")
return value

1
frontend/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import frontend_bp

3
frontend/models.py Normal file
View File

@@ -0,0 +1,3 @@
from extensions import db
# Frontend-specific models
# Note: Log model is in auth/models.py as it's related to administrator functionality

425
frontend/routes.py Normal file
View File

@@ -0,0 +1,425 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_required, current_user
from auth.models import User, Company, UserCompany
from api.models import Log
from extensions import db
from datetime import datetime, timedelta
from sqlalchemy import or_
import pytz
import logging
# Initialize logger for this module
logger = logging.getLogger(__name__)
frontend = Blueprint('frontend', __name__)
frontend_bp = frontend
@frontend.route('/')
@frontend.route('/home')
def index():
try:
return render_template('frontend/home.html')
except Exception as e:
logger.exception("Home page error from IP %s: %s", request.remote_addr, str(e))
flash('An error occurred while loading the home page.', 'error')
return render_template('frontend/home.html') # Fallback to basic template
@frontend.route('/about')
def about():
try:
return render_template('frontend/about.html')
except Exception as e:
logger.exception("About page error from IP %s: %s", request.remote_addr, str(e))
flash('An error occurred while loading the about page.', 'error')
return render_template('frontend/about.html') # Fallback to basic template
@frontend.route('/profile')
@login_required
def profile():
try:
user = User.query.filter_by(id=current_user.id).first()
return render_template('frontend/profile.html', user=user)
except Exception as e:
logger.exception(
"Profile page error for user %s (ID: %s) from IP %s: %s",
current_user.username if current_user.is_authenticated else 'anonymous',
current_user.id if current_user.is_authenticated else 'N/A',
request.remote_addr,
str(e)
)
flash('An error occurred while loading your profile. Please try again.', 'error')
return redirect(url_for('frontend.index'))
@frontend.route('/dashboard')
@login_required
def dashboard():
try:
# Get app timezone
app_tz = pytz.timezone(current_app.config['TIMEZONE'])
# Get date range from request or default to last 48 hours
date_range = request.args.get('daterange')
if date_range:
start_str, end_str = date_range.split(' - ')
start_date = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_date = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
# Make these timezone aware using the app's timezone
start_date = app_tz.localize(start_date)
end_date = app_tz.localize(end_date)
else:
end_date = datetime.now(app_tz) + timedelta(hours=1) # Include 1 hour in the future
start_date = end_date - timedelta(hours=49) # Look back 48 hours from future end time
# Get company filter if provided
company_id = request.args.get('company_id', type=int)
# Build base query with date range filter and eagerly load relationships
from auth.models import ApiKey, Company
# Convert timezone-aware dates to naive for comparison with database timestamps
# since the log timestamps are stored as naive datetime objects
start_date_naive = start_date.replace(tzinfo=None)
end_date_naive = end_date.replace(tzinfo=None)
# Use joinedload to eagerly load the API key and company relationships
query = Log.query.options(
db.joinedload(Log.api_key),
db.joinedload(Log.company)
).filter(Log.timestamp.between(start_date_naive, end_date_naive))
# Apply company-specific filtering based on user role
if current_user.is_global_admin():
# GlobalAdmin should be allowed to see all records, no matter what company/site
if company_id:
query = query.filter(Log.company_id == company_id)
# If no company_id specified, show all logs (no additional filtering)
else:
# CompanyAdmin and User should see only company log events and API Keys (sites)
# for companies they are member of
user_company_ids = [uc.company_id for uc in current_user.companies]
if not user_company_ids:
# If user has no company associations, show no logs
query = query.filter(Log.id == -1) # Impossible condition = no results
else:
if company_id and company_id in user_company_ids:
# Filter by the specific company if requested and user has access
query = query.filter(Log.company_id == company_id)
else:
# Show logs from all companies the user has access to
query = query.filter(Log.company_id.in_(user_company_ids))
# Get the logs ordered by timestamp (newest first)
logs = query.order_by(Log.timestamp.desc()).all()
# Get companies for the dropdown filter based on user role
if current_user.is_global_admin():
# GlobalAdmin can see all companies
companies = Company.query.all()
else:
# CompanyAdmin and User should see only companies they are member of
companies = current_user.get_companies()
return render_template('frontend/dashboard.html',
title='Dashboard',
logs=logs,
start_date=start_date,
end_date=end_date,
companies=companies,
selected_company_id=company_id)
except Exception as e:
logger.exception(
"Dashboard error for user %s (ID: %s, role: %s) from IP %s: %s",
current_user.username if current_user.is_authenticated else 'anonymous',
current_user.id if current_user.is_authenticated else 'N/A',
current_user.role if current_user.is_authenticated else 'N/A',
request.remote_addr,
str(e)
)
flash('An error occurred while loading the dashboard. Please try again.', 'error')
return redirect(url_for('frontend.index'))
@frontend.route('/time_spent_report')
@login_required
def time_spent_report():
try:
# Get app timezone
app_tz = pytz.timezone(current_app.config['TIMEZONE'])
# Get date range from request or default to last 7 days
date_range = request.args.get('daterange')
if date_range:
start_str, end_str = date_range.split(' - ')
start_date = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_date = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
# Make these timezone aware using the app's timezone
start_date = app_tz.localize(start_date)
end_date = app_tz.localize(end_date)
else:
end_date = datetime.now(app_tz) + timedelta(hours=1) # Include 1 hour in the future
start_date = end_date - timedelta(days=7, hours=1) # Look back 7 days from future end time
# Get filters
company_id = request.args.get('company_id', type=int)
api_key_id = request.args.get('api_key_id', type=int)
group_by = request.args.get('group_by', 'user') # Default to user grouping
continue_iterate = request.args.get('continue_iterate') == 'on' # Get checkbox value
# Build base query with date range filter
from auth.models import ApiKey, Company
# Start with all the login/logout events within the date range
# Convert timezone-aware dates to naive for comparison with database timestamps
start_date_naive = start_date.replace(tzinfo=None)
end_date_naive = end_date.replace(tzinfo=None)
logs_query = Log.query.filter(
Log.timestamp.between(start_date_naive, end_date_naive)
).order_by(Log.timestamp.asc())
# Apply company-specific filtering based on user role
if current_user.is_global_admin():
# GlobalAdmin should be allowed to see all records, no matter what company/site
if company_id:
logs_query = logs_query.filter(Log.company_id == company_id)
# If no company_id specified, show all logs (no additional filtering)
else:
# CompanyAdmin and User should see only company log events for companies they are member of
user_company_ids = [uc.company_id for uc in current_user.companies]
if not user_company_ids:
# If user has no company associations, show no logs
logs_query = logs_query.filter(Log.id == -1) # Impossible condition = no results
else:
if company_id and company_id in user_company_ids:
# Filter by the specific company if requested and user has access
logs_query = logs_query.filter(Log.company_id == company_id)
else:
# Show logs from all companies the user has access to
logs_query = logs_query.filter(Log.company_id.in_(user_company_ids))
# Apply API key filter if provided
if api_key_id:
# Ensure the API key belongs to a company the user has access to
api_key = ApiKey.query.get(api_key_id)
if api_key:
if current_user.is_global_admin():
# GlobalAdmin can use any API key
logs_query = logs_query.filter(Log.api_key_id == api_key_id)
else:
# Check if the API key belongs to a company the user has access to
user_company_ids = [uc.company_id for uc in current_user.companies]
if api_key.company_id in user_company_ids:
logs_query = logs_query.filter(Log.api_key_id == api_key_id)
# If API key doesn't belong to user's company, ignore the filter
# Get all the relevant logs
logs = logs_query.all()
# Process logs to calculate time spent
time_data = calculate_time_spent(logs, group_by, continue_iterate)
# Get all companies for the dropdown filter
if current_user.is_global_admin():
companies = Company.query.all()
else:
companies = current_user.get_companies()
# Get available API keys for filter
if current_user.is_global_admin():
api_keys = ApiKey.query.all()
else:
# Get API keys for companies user has access to
user_company_ids = [uc.company_id for uc in current_user.companies]
api_keys = ApiKey.query.filter(ApiKey.company_id.in_(user_company_ids)).all()
return render_template('frontend/time_spent_report.html',
title='Time Spent Report',
time_data=time_data,
start_date=start_date,
end_date=end_date,
companies=companies,
api_keys=api_keys,
selected_company_id=company_id,
selected_api_key_id=api_key_id,
group_by=group_by,
continue_iterate=continue_iterate)
except Exception as e:
logger.exception(
"Time spent report error for user %s (ID: %s, role: %s) from IP %s: %s",
current_user.username if current_user.is_authenticated else 'anonymous',
current_user.id if current_user.is_authenticated else 'N/A',
current_user.role if current_user.is_authenticated else 'N/A',
request.remote_addr,
str(e)
)
flash('An error occurred while generating the time spent report. Please try again.', 'error')
return redirect(url_for('frontend.dashboard'))
def calculate_time_spent(logs, group_by='user', continue_iterate=False):
"""
Calculate time spent by users based on login/logout events.
Args:
logs: List of Log objects sorted by timestamp
group_by: Whether to group by 'user' or 'user_computer'
continue_iterate: Whether to continue iterating for additional login/logout pairs
Returns:
List of dictionaries with time spent information
"""
from auth.models import Company, ApiKey
# Dictionary to track user sessions
# Key: user_name or user_name+computer_name depending on group_by
# Value: dictionary with session tracking info
active_sessions = {}
# Dictionary to accumulate total time spent
# Key: date + user_name + (computer_name) + company_id
# Value: dictionary with accumulated time and session details
time_totals = {}
# Define login and logout event types - case-insensitive matching
login_events = ['login', 'unlock', 'logon'] # Events that start a session
logout_events = ['logout', 'lock', 'logoff'] # Events that end a session
# Create a set to track which users have appeared in logs
seen_users = set()
# First pass: populate the time_totals dictionary with user entries
# This ensures every user has an entry even if they don't have paired login/logout events
for log in logs:
# Determine the session key based on grouping option
session_key = log.user_name
if group_by == 'user_computer':
session_key = f"{log.user_name}:{log.computer_name}"
# Get date string in format YYYY-MM-DD
log_date = log.timestamp.strftime('%Y-%m-%d')
# Create a unique key for the time totals
total_key = f"{log_date}:{session_key}:{log.company_id}"
# Initialize the time total entry if it doesn't exist
if total_key not in time_totals:
# Get company info
company = Company.query.get(log.company_id) if log.company_id else None
company_name = company.name if company else "Unknown"
# Get API key info
api_key = ApiKey.query.get(log.api_key_id) if log.api_key_id else None
api_key_description = api_key.description if api_key else "Unknown"
# Initialize with zero time
time_totals[total_key] = {
'date': log_date,
'user_name': log.user_name,
'computer_name': log.computer_name if group_by == 'user_computer' else None,
'company_id': log.company_id,
'company_name': company_name,
'api_key_id': log.api_key_id,
'api_key_description': api_key_description,
'total_seconds': 0,
'first_login': None,
'last_logout': None,
'session_count': 0
}
# Track that we've seen this user
seen_users.add(log.user_name)
# Update login/logout timestamps even if we can't calculate duration
event_type = log.event_type.lower() if log.event_type else ''
# For all users, record their first login and last logout
if event_type in login_events:
if not time_totals[total_key]['first_login'] or log.timestamp < time_totals[total_key]['first_login']:
time_totals[total_key]['first_login'] = log.timestamp
if event_type in logout_events:
if not time_totals[total_key]['last_logout'] or log.timestamp > time_totals[total_key]['last_logout']:
time_totals[total_key]['last_logout'] = log.timestamp
# Second pass: calculate session durations
for log_index, log in enumerate(logs):
# Determine the session key based on grouping option
session_key = log.user_name
if group_by == 'user_computer':
session_key = f"{log.user_name}:{log.computer_name}"
# Get date string in format YYYY-MM-DD
log_date = log.timestamp.strftime('%Y-%m-%d')
# Create a unique key for the time totals
total_key = f"{log_date}:{session_key}:{log.company_id}"
# Convert event_type to lowercase for case-insensitive comparison
event_type = log.event_type.lower() if log.event_type else ''
# Process any login-like event
if event_type in login_events:
# Record login time for this session
active_sessions[session_key] = {
'login_time': log.timestamp,
'log_index': log_index,
'company_id': log.company_id,
'date': log_date
}
# Process any logout-like event
elif event_type in logout_events and session_key in active_sessions:
session = active_sessions[session_key]
# Only process if session is from the same day and company
if session['date'] == log_date and session['company_id'] == log.company_id:
# Calculate duration of this session
duration = (log.timestamp - session['login_time']).total_seconds()
# Only count if duration is positive and reasonable (< 24 hours)
if 0 < duration < 86400: # 24 hours = 86400 seconds
# Add to the total time for this user/day combination
time_totals[total_key]['total_seconds'] += duration
time_totals[total_key]['session_count'] += 1
# If we should continue iterating, leave the session active to match with future login events
# Otherwise, remove the active session after processing
if not continue_iterate:
del active_sessions[session_key]
else:
# If the session doesn't match day/company, remove it if we're not continuing to iterate
if not continue_iterate:
del active_sessions[session_key]
# Special case: If we only have one event of each type per user per day,
# calculate duration between first login and last logout
for total_key, entry in time_totals.items():
if entry['session_count'] == 0 and entry['first_login'] and entry['last_logout']:
# Calculate duration between first login and last logout
duration = (entry['last_logout'] - entry['first_login']).total_seconds()
# Only use if duration is positive and reasonable
if 0 < duration < 86400: # 24 hours = 86400 seconds
entry['total_seconds'] += duration
entry['session_count'] = 1
# Convert the time_totals dictionary to a list of dictionaries
result = []
for entry in time_totals.values():
# Format the total time as hours:minutes:seconds
hours, remainder = divmod(entry['total_seconds'], 3600)
minutes, seconds = divmod(remainder, 60)
entry['formatted_time'] = f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
result.append(entry)
# Sort by date (newest first) and then by user_name
result.sort(key=lambda x: (x['date'], x['user_name']), reverse=True)
return result

128
info.md Normal file
View File

@@ -0,0 +1,128 @@
# Domain Login Monitoring System
## Setup Instructions
#### pip install flask flask_sqlalchemy flask_bcrypt flask_login flask_wtf flask_bootstrap email-validator pyotp qrcode pillow
### 1. Generate SSL Certificate
First, generate a self-signed certificate using OpenSSL:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"
```
### Testing the app functionality from terminal:
```
curl -k -X POST -H "Content-Type: application/json" -H "X-API-Key: 47959c6d5d8db64eb0ec3ad824ccbe82618632e2a58823d84aba92078da693fa" -d '{"EventType": "Test", "UserName": "Testuser", "ComputerName": "TEST-PC", "IPAddress": "192.168.1.100", "Timestamp": "2025-04-27 08:50:35 BST", "retry": 1}' https://localhost:5000/api/log_event
curl -k -X POST -H "Content-Type: application/json" -H "X-API-Key: 47959c6d5d8db64eb0ec3ad824ccbe82618632e2a58823d84aba92078da693fa" -d '{"EventType": "Test", "UserName": "Testuser", "ComputerName": "TEST-PC", "IPAddress": "192.168.1.100", "Timestamp": "2025-04-27 08:51:35Z"}' https://localhost:8000/api/log_event
```
### 2. PowerShell Script
Save this script as LogEvent.ps1:
```powershell
function Send-LogData {
param (
[string]$EventType,
[string]$ApiKey # Add your API key here
)
# Get the user name and computer name
$UserName = $env:USERNAME
$ComputerName = $env:COMPUTERNAME
# Get the IP address of the default network interface
$IPAddress = Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
$_.InterfaceAlias -match "Ethernet|Wi-Fi" -and $_.IPAddress -ne "127.0.0.1"
} | Select-Object -ExpandProperty IPAddress -First 1
# Get the current timestamp in ISO format
$Timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# Prepare the log data
$LogData = @{
EventType = $EventType
UserName = $UserName
ComputerName = $ComputerName
IPAddress = $IPAddress
Timestamp = $Timestamp
}
# Convert the log data to JSON
$LogJson = $LogData | ConvertTo-Json
# Send the log data using Invoke-RestMethod
try {
$response = Invoke-RestMethod -Uri "https://yourserver.com:5000/log_event" `
-Method Post `
-Body $LogJson `
-Headers @{
"Content-Type" = "application/json"
"X-API-Key" = $ApiKey
} `
-SkipCertificateCheck
Write-Host "Log sent successfully: $EventType"
}
catch {
Write-Error "Failed to send log: $_"
}
}
```
### 3. Task Scheduler Setup
Create Tasks for Each Event:
#### User Sign-In:
1. Open Task Scheduler
2. Click Create Task
3. Name the task (e.g., "Log User Sign-In")
4. Go to the Triggers tab and click New
5. Select On an event
6. Set Log to Security and Source to Microsoft-Windows-Security-Auditing
7. Set Event ID to 4624 (successful logon)
8. Go to the Actions tab and click New
9. Set Action to Start a program
10. Set Program/script to powershell.exe
11. Set Add arguments to:
```
-File "C:\Path\To\LogEvent.ps1" -EventType "Sign-In" -ApiKey "YOUR_API_KEY_HERE"
```
#### PC Lock:
Same as above but:
- Set Event ID to 4800 (workstation lock)
- Change EventType to "Lock"
#### User Sign-Out:
Same as above but:
- Set Event ID to 4647 (user initiated logoff)
- Change EventType to "Sign-Out"
### 4. Initial Setup
1. Log in to the web interface using default credentials:
- Username: superadmin
- Password: adminsuper
- Email: superadmin@example.com
2. Go to Admin Settings to generate an API key
3. Update all task scheduler tasks with the generated API key
### For compiling you would need pyinstall with optional upx
https://upx.github.io/
```
flask_app/
├── app.py
├── config.py
├── models.py
├── forms.py
├── templates/
│ ├── base.html
│ ├── login.html
│ ├── register.html
│ ├── dashboard.html
│ ├── logs.html
│ └── manage_users.html
├── static/
│ ├── css/
│ └── js/
└── database.db
```

34
instance/certs/cert.pem Normal file
View File

@@ -0,0 +1,34 @@
-----BEGIN CERTIFICATE-----
MIIF7zCCA9egAwIBAgIUZPH8NtUboyhjRENnydLCoYy7ZTAwDQYJKoZIhvcNAQEL
BQAwgYYxCzAJBgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcM
CENpdHlOYW1lMRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFu
eVNlY3Rpb25OYW1lMR0wGwYDVQQDDBRDb21tb25OYW1lT3JIb3N0bmFtZTAeFw0y
NTA1MjUwMzM2NTJaFw0zNTA1MjMwMzM2NTJaMIGGMQswCQYDVQQGEwJYWDESMBAG
A1UECAwJU3RhdGVOYW1lMREwDwYDVQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29t
cGFueU5hbWUxGzAZBgNVBAsMEkNvbXBhbnlTZWN0aW9uTmFtZTEdMBsGA1UEAwwU
Q29tbW9uTmFtZU9ySG9zdG5hbWUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
AoICAQC9oguQAa1YeTqOXjNMuXnOJwSZFYI20Yeb4VgapqL4obFi+JvbbgkiqqiL
1gy+aID6KIpVJEBauzpx5/SI2anC51FTfY/n6R/NwBCJW4CazDUm2qE5Q7MaPUSI
ymOvR70XanxXvPev9VLpAcaRd1rD/sSOuCjPEVJiMnhoeoYlXn9/uiLAYHqeF3QY
0jVYK4vyRG9SZF6lUyV0b6wBd5uIKUDU+LIyjl3iIkRk8M41Uqg4TLLQnXVa3vVR
LDW2y1cOtWmnz8WZmvVT2QBrh4g6s2+t3OGfkfo+OKYYAA+owGfW7CzrsPf9sPhB
7xIk0/05GKSxDT83fwMDv5Ie8Oa0BEJ7LUHw5KWF7wJMfzWYobMgngtEZReImOXF
AwAo/K7g25rqC2wlLgqeAZqs1FQ8tXEZ28mMmLKWADVG5bgtBxTYisVsmcgSpulo
o3iBQzHZGrM1BcMrDwjSPuF7i+dHsM1pxw+adBRs65U0CeidyhcSWzRN/ApZUMcy
FoCwxpPwi/zd5Hd1n5uNk8KjMpc6sY+paKsfM91A3TZlPmAOjhRsGfbcFcX4MK58
XRB6Kju8JFoUduh3DySZtQ89MFVzez96VNTVCDg6kl3B03++gxCNRy5XlmRI6nbb
o/WEnXI5ffWQhvd+wz0rr8Fg3/7+w1ljhf9aRh3MCSScPZE5swIDAQABo1MwUTAd
BgNVHQ4EFgQUnNq+CjzjU5fUeJtqf8PvJ6DVgZMwHwYDVR0jBBgwFoAUnNq+Cjzj
U5fUeJtqf8PvJ6DVgZMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AgEAHVFCdCRgiRBlQXP7P8eX4npRYVJnRcCzKL2ALpVu9l8ZZJOOJEPX7Mn6E8OT
Dcwca5k9d8Kdyc+/8giQ4ank93ZBU/Ptgd/m++KsTUMmwVbytAQ7uer0lhyYcOUj
OnNvM3HY5llRHcVvQ697RS22GNzOe3l/a5DU5PTHq3kdeVcqhTNzbL0pyNOKWcrg
uBZWLQjfWyQhU6eZPqnQLbeTXLtg+PGl7zM3sRLj4mU+chFQ1Q3hQALl9onpr150
jPfntUkKJH4RAGpTYokbhnGfGbUiuKrDGqUuuCWIzOxw+65edwIpgz+/h7jOQeVR
QlCFgXjHplIJHIo5u1wvYxBRAJ1oMrJcvD4lDTyLYrSdniOXQOKOHsaf9GC+6wp/
LUk2pPSqvH8JzYwSIvbtg1BQpDYgxhnphAx4nNpF5U2X04hkjpK7LZrV2qKGhJDo
bTcLk6LhX1ssS5udvU7Vgmof0o5n4HaS6rcDaSp8ywnHcBAtNt8sme3/NQxU0wZs
iU3+LrmTm9kEztFYznfUyPDBjjIctHq/k7sCFPlbqGsjcGDfCd0hpHOT/xaS2CYV
0c8XzkU1B3VC4pNmtLYs0THRy0NSgYVkL7iENXLe1jBhspcLrDKRp0Ke1hPmcFLY
oVRbSDRvbKL+htXFAu2bCM1uW4tnyZQzKgIHjxrEjiR4VbU=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,7 @@
#!/bin/bash
openssl req -x509 -newkey rsa:4096 \
-keyout instance/certs/key.pem \
-out instance/certs/cert.pem \
-sha256 -days 3650 \
-nodes \
-subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"

52
instance/certs/key.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC9oguQAa1YeTqO
XjNMuXnOJwSZFYI20Yeb4VgapqL4obFi+JvbbgkiqqiL1gy+aID6KIpVJEBauzpx
5/SI2anC51FTfY/n6R/NwBCJW4CazDUm2qE5Q7MaPUSIymOvR70XanxXvPev9VLp
AcaRd1rD/sSOuCjPEVJiMnhoeoYlXn9/uiLAYHqeF3QY0jVYK4vyRG9SZF6lUyV0
b6wBd5uIKUDU+LIyjl3iIkRk8M41Uqg4TLLQnXVa3vVRLDW2y1cOtWmnz8WZmvVT
2QBrh4g6s2+t3OGfkfo+OKYYAA+owGfW7CzrsPf9sPhB7xIk0/05GKSxDT83fwMD
v5Ie8Oa0BEJ7LUHw5KWF7wJMfzWYobMgngtEZReImOXFAwAo/K7g25rqC2wlLgqe
AZqs1FQ8tXEZ28mMmLKWADVG5bgtBxTYisVsmcgSpuloo3iBQzHZGrM1BcMrDwjS
PuF7i+dHsM1pxw+adBRs65U0CeidyhcSWzRN/ApZUMcyFoCwxpPwi/zd5Hd1n5uN
k8KjMpc6sY+paKsfM91A3TZlPmAOjhRsGfbcFcX4MK58XRB6Kju8JFoUduh3DySZ
tQ89MFVzez96VNTVCDg6kl3B03++gxCNRy5XlmRI6nbbo/WEnXI5ffWQhvd+wz0r
r8Fg3/7+w1ljhf9aRh3MCSScPZE5swIDAQABAoICAC2CZiP5QxCoh1UDZmxTVtgS
pRfYAZgGUPUn72z18Maah2epIj5W+fpH2os0o3pOuiVO9WPZf1hG9o+/iwAMvKD5
wpq214JggDFwlodgXkzIFTlt3qNPi/wQGBJ7/9Bg9xBXjd/AifDAf1VMB8uBSVcg
HSvjJmgLUCog0qTAQtFVDGQq14wzmzm1hzctu3+dc8iAg/bR/6TNf3+iDTWM7taO
j/CMfreDUySh9KgE4ngJjjV0srU+FJvqRhVk9r8XrZzqDKEpS9LBTX8B5QfpTthH
l9Wx3LPe5J9qGPJJkXh+NG1v4JfvsJRBlFK+fSw5c9vv/hY/h5xZ7u9HWlnylmrT
mZ9RIvv72Yp1WSUjuYTtV7ABt02xq55sP/UJOyXvcXaLe7Ow8yc8wUFNK3zhnyMX
9ocCcmAuToAHVOq5vIM1JzC8Ss8pFH0eD3DXkYSEO9wFwNOZPBAlQXyuw9+R44E9
ykf74cnLP787OcV92WUWYkRz69tAyFJdFCNyZ2u8KCDy7/g2QMuoixekwo7nKyvu
g3kxvGGHMsRSMEf6zVXd0a/z0q2CGGUv1SE1pOIZUW2ZAiXNWTrFvv/y/R12eQhP
r4q27Yc8J0K+om8OF3Fyjmi4OyJ930ZXQl7gJinogeXisrEAeqLVimNym7G19vlc
gsU/DeY0uLQeLTLzxTABAoIBAQDcafIRTJo0jpUVEVuiLTf0GebKKgQe0Oz7C7SK
T/mLPH4X0oFXN+vUbx6ZF/SvEQCwzDjqBy1gtDxQPzaFlavGD7Ub5a/bv/cnV7m4
3Kp8/H461ChtVfX5q7ZlWlaBQmn0FZMdAtU/bmEw/x6jFcPDL6kEPEs00I2Uugfd
Jg6L8UO21rfHImVcisBtEqWcn6u0vM5VUo1dcTbrhXSMrMsvbS2xrF88YF6gNUoQ
MERp/LLX4pbvk5fJ4gFrCjO1Oe7TuseNfZMLHhE1VOCOmDMlvzwxTDIhv0BZ73CR
tAMvLLHxCN9pkKyaal7Rkpk7dknrLcQ8rmyiBWycwrL9Zk6BAoIBAQDcP+CytVIE
5iNkbgkiVAVquU005ngezLn1YbncKx7dJK5WoJO6mnBNOqZ/ewDaEF/Xbvyp/S+M
p2UNHRg10+fV0LJZ2hT2vtOUMufV/yAfAgvwVFmEo76RTO5FVfN3c9rZ9Xc/YEZt
AYuuMxwMeTeGIhd2/w5Kru/Yl7Qk3SNXvYvWslZz6G8qecAh1W62nevNb4HstkU2
tdAMcLWoPQSvhfIDM88RCD+VQO1fRFkyxt3l3NDfodORaADy0wcUIgx0m3JZC8OG
HIX6xPIwOZYjw/tL/VEPkfQlF1Ws3h59cdQKkkNsYdGiv6SM8YJXkQ0Ska5ciVr6
UHstVm8YMJYzAoIBAQDOsuAaTv7xuKCgMDYBoWwukze2cK6Kg50pVHHLn3JCm8kX
6AX5V+zlvAsywJ9qqYQ/SFU7St3IKV3CV3V20sRSqhpKfhxr9Nr/XypA7VdIfLSX
0KvU1N8mc1xKMeybrT+VccITW7vFj2q/uw/tGpUJ7yEOYsiYT9fmGIsVXgIYRHoe
9b9ElMH/hfMslmcOuUIZ7VGF/DOr5Gb/eZix771fzYAjdaWeBjXXAgJhqhIOXrcM
82ZeZ8fZwANacSfKlPieQDOxQYjqzRiQLfekYaDdjjgRdwYwVZ0wefXT/b9atwxs
IMj6w3zKFmSzHkpq0+RAExxLV7tyOaoAXCnkrtOBAoIBAClGp1uWc4qLfrKBlKCk
UmeP1pJFZtmO0ILWD7jdM+mJyEpfyY+9BbLTfQSDDsPPMcbz+9H3qwOXE28DttfP
oLEHbYU9Q5SCarBpYd1O9Lwa7BXcGPKspTghzL2dwATw52DVicWMy2X+VikNVwJX
bTpsBS292vXQFw7mT1JhRxBYa26O+Xi7ZKn3KzSsBRWgPuK/NQAhoJMCO705GjIv
TUN/vL0w5mtwuknEYzfpXTYQ4uEDIvnmH/ouHY9kUP1K7D6mKyXY+ImXqtw2MJUt
FaAaSGwTSy+50KFq4BmHfvtPa8eXZZ9YLatscvAfCqhSfLqwJpcc/rnOf2cdvbAw
2tUCggEANPi1koK6ZNmMDx9wz205kyB18KAcWNb/ZwI/Gbsp1VbUb2P1pFQwYGsW
qTgm2cPAIe31PyjCj6Ugnw0UmoCPbwOfu0T7hjiMLDGugnDqmuqtK1fODbuuNrHu
g5rOtRECREPQZTxMlxSVMruMaQq2Tyu6tky42rFUMTc5wP14O+U0hXrtVSeKk3uS
dv9AYIsK/67OKGBphfpxk9Y5oaKRO7BzAJSl22EN0yOvavi/DN4tcudap2viEmgG
2syX6QQoX8TfX+tixa2FPTkQRRyDmJRKAmab6acKedjIApMvl13pAjRMs6TvhzMP
NdF3HK9zpB3w25vIFq7AJ8GpxjEbDA==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,54 @@
FROM python:alpine
# Environment variables for PostgreSQL and Redis
# openssl rand -base64 18
ENV POSTGRES_DB=basedbapp \
POSTGRES_USER=maindbuser \
POSTGRES_PASSWORD='4rV+ICvrlz3js7MiSvuFqZ47' \
REDIS_PASSWORD='JgA3Pa7XOWGHKws3JvzPz0vc' \
PG_SHARED_BUFFERS='256MB' \
PG_WORK_MEM='16MB' \
PG_EFFECTIVE_CACHE_SIZE='768MB' \
REDIS_MAXMEMORY='256mb' \
TZ=Europe/London \
GUID=1000 \
UUID=1000 \
GIT_REPO_URL=https://github.com/yourusername/your-repo.git
# Install dependencies ( sqlite3 libsqlite3-dev )
RUN groupadd -g $GUID appuser && \
adduser -u $UUID -D appuser -s /bin/bash -m appuser && \
apk add --no-cache postgresql postgresql-dev redis net-tools \
iputils-ping git curl openssl && \
git clone $GIT_REPO_URL /app && chown -R appuser:appuser /app
# Initialize PostgreSQL
RUN mkdir -p /run/postgresql && chown postgres:postgres /run/postgresql
USER postgres
RUN initdb -D /var/lib/postgresql/data && \
echo "shared_buffers = $PG_SHARED_BUFFERS" >> /var/lib/postgresql/data/postgresql.conf && \
echo "work_mem = $PG_WORK_MEM" >> /var/lib/postgresql/data/postgresql.conf && \
echo "effective_cache_size = $PG_EFFECTIVE_CACHE_SIZE" >> /var/lib/postgresql/data/postgresql.conf && \
pg_ctl -D /var/lib/postgresql/data -l logfile start && \
psql -c "CREATE USER \"$POSTGRES_USER\" WITH PASSWORD '$POSTGRES_PASSWORD';" && \
createdb -O "$POSTGRES_USER" "$POSTGRES_DB" && \
pg_ctl -D /var/lib/postgresql/data stop
USER root
# Configure Redis
RUN echo "requirepass $REDIS_PASSWORD" >> /etc/redis.conf && \
echo "maxmemory $REDIS_MAXMEMORY" >> /etc/redis.conf && \
echo "maxmemory-policy allkeys-lru" >> /etc/redis.conf
WORKDIR /app
USER appuser
RUN pip install --no-cache-dir -r requirements.txt && \
/bin/bash instance/certs/gen_certs.sh
# Persistent volumes for PostgreSQL and Redis
VOLUME ["/var/lib/postgresql/data", "/data"]
# Postresql 5432 Redis 6379
EXPOSE 8000
USER root
CMD service postgresql start && redis-server /etc/redis.conf --daemonize yes && su - appuser -c "python /app/app.py"

28
requirements.txt Normal file
View File

@@ -0,0 +1,28 @@
flask
asgiref
flask_sqlalchemy
flask_bcrypt
flask_jwt_extended
flask_login
pyotp
pytz
flask_wtf
email-validator
qrcode
flask-compress
pillow
# serving the application
uvicorn[standard]
# not needed?
#flask_login
#flask_bootstrap
#httpx[http2]
# Production enhancements
redis # Optional: for distributed rate limiting
#psutil # System monitoring for health checks
#gunicorn # Production WSGI server
#click # CLI tools (for database backups)

14
run.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Start the application using the built-in uvicorn setup
# Configuration is now read from config.ini
#source ~/Documents/Projects/domain-logons/.venv/bin/activate && python app.py
source .venv/bin/activate && python app.py
# Legacy way to start with explicit uvicorn command (no longer needed):
# source ~/Documents/Projects/domain-logons/.venv/bin/activate && uvicorn app:wsg --host 0.0.0.0 --port 8000 \
# --reload --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
# Run Flask app with Hypercorn (alternative ASGI server):
# hypercorn app:wsg --keyfile=certs/key.pem \
# --certfile=certs/cert.pem --bind "0.0.0.0:8000"

6
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
static/css/buttons.bootstrap5.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
static/css/buttons.dataTables.min.css vendored Normal file

File diff suppressed because one or more lines are too long

15
static/css/custom.css Normal file
View File

@@ -0,0 +1,15 @@
[data-bs-theme="dark"] {
--bs-body-bg: #212529;
--bs-body-color: #f8f9fa;
--bs-card-bg: #2c3034;
--bs-dropdown-bg: #2c3034;
--bs-dropdown-link-hover-bg: #373b3e;
--bs-dropdown-link-color: #f8f9fa;
--bs-dropdown-link-hover-color: #ffffff;
}
[data-bs-theme="light"] {
--bs-body-bg: #ffffff;
--bs-body-color: #212529;
--bs-card-bg: #ffffff;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,410 @@
.daterangepicker {
position: absolute;
color: inherit;
background-color: #fff;
border-radius: 4px;
border: 1px solid #ddd;
width: 278px;
max-width: none;
padding: 0;
margin-top: 7px;
top: 100px;
left: 20px;
z-index: 3001;
display: none;
font-family: arial;
font-size: 15px;
line-height: 1em;
}
.daterangepicker:before, .daterangepicker:after {
position: absolute;
display: inline-block;
border-bottom-color: rgba(0, 0, 0, 0.2);
content: '';
}
.daterangepicker:before {
top: -7px;
border-right: 7px solid transparent;
border-left: 7px solid transparent;
border-bottom: 7px solid #ccc;
}
.daterangepicker:after {
top: -6px;
border-right: 6px solid transparent;
border-bottom: 6px solid #fff;
border-left: 6px solid transparent;
}
.daterangepicker.opensleft:before {
right: 9px;
}
.daterangepicker.opensleft:after {
right: 10px;
}
.daterangepicker.openscenter:before {
left: 0;
right: 0;
width: 0;
margin-left: auto;
margin-right: auto;
}
.daterangepicker.openscenter:after {
left: 0;
right: 0;
width: 0;
margin-left: auto;
margin-right: auto;
}
.daterangepicker.opensright:before {
left: 9px;
}
.daterangepicker.opensright:after {
left: 10px;
}
.daterangepicker.drop-up {
margin-top: -7px;
}
.daterangepicker.drop-up:before {
top: initial;
bottom: -7px;
border-bottom: initial;
border-top: 7px solid #ccc;
}
.daterangepicker.drop-up:after {
top: initial;
bottom: -6px;
border-bottom: initial;
border-top: 6px solid #fff;
}
.daterangepicker.single .daterangepicker .ranges, .daterangepicker.single .drp-calendar {
float: none;
}
.daterangepicker.single .drp-selected {
display: none;
}
.daterangepicker.show-calendar .drp-calendar {
display: block;
}
.daterangepicker.show-calendar .drp-buttons {
display: block;
}
.daterangepicker.auto-apply .drp-buttons {
display: none;
}
.daterangepicker .drp-calendar {
display: none;
max-width: 270px;
}
.daterangepicker .drp-calendar.left {
padding: 8px 0 8px 8px;
}
.daterangepicker .drp-calendar.right {
padding: 8px;
}
.daterangepicker .drp-calendar.single .calendar-table {
border: none;
}
.daterangepicker .calendar-table .next span, .daterangepicker .calendar-table .prev span {
color: #fff;
border: solid black;
border-width: 0 2px 2px 0;
border-radius: 0;
display: inline-block;
padding: 3px;
}
.daterangepicker .calendar-table .next span {
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.daterangepicker .calendar-table .prev span {
transform: rotate(135deg);
-webkit-transform: rotate(135deg);
}
.daterangepicker .calendar-table th, .daterangepicker .calendar-table td {
white-space: nowrap;
text-align: center;
vertical-align: middle;
min-width: 32px;
width: 32px;
height: 24px;
line-height: 24px;
font-size: 12px;
border-radius: 4px;
border: 1px solid transparent;
white-space: nowrap;
cursor: pointer;
}
.daterangepicker .calendar-table {
border: 1px solid #fff;
border-radius: 4px;
background-color: #fff;
}
.daterangepicker .calendar-table table {
width: 100%;
margin: 0;
border-spacing: 0;
border-collapse: collapse;
}
.daterangepicker td.available:hover, .daterangepicker th.available:hover {
background-color: #eee;
border-color: transparent;
color: inherit;
}
.daterangepicker td.week, .daterangepicker th.week {
font-size: 80%;
color: #ccc;
}
.daterangepicker td.off, .daterangepicker td.off.in-range, .daterangepicker td.off.start-date, .daterangepicker td.off.end-date {
background-color: #fff;
border-color: transparent;
color: #999;
}
.daterangepicker td.in-range {
background-color: #ebf4f8;
border-color: transparent;
color: #000;
border-radius: 0;
}
.daterangepicker td.start-date {
border-radius: 4px 0 0 4px;
}
.daterangepicker td.end-date {
border-radius: 0 4px 4px 0;
}
.daterangepicker td.start-date.end-date {
border-radius: 4px;
}
.daterangepicker td.active, .daterangepicker td.active:hover {
background-color: #357ebd;
border-color: transparent;
color: #fff;
}
.daterangepicker th.month {
width: auto;
}
.daterangepicker td.disabled, .daterangepicker option.disabled {
color: #999;
cursor: not-allowed;
text-decoration: line-through;
}
.daterangepicker select.monthselect, .daterangepicker select.yearselect {
font-size: 12px;
padding: 1px;
height: auto;
margin: 0;
cursor: default;
}
.daterangepicker select.monthselect {
margin-right: 2%;
width: 56%;
}
.daterangepicker select.yearselect {
width: 40%;
}
.daterangepicker select.hourselect, .daterangepicker select.minuteselect, .daterangepicker select.secondselect, .daterangepicker select.ampmselect {
width: 50px;
margin: 0 auto;
background: #eee;
border: 1px solid #eee;
padding: 2px;
outline: 0;
font-size: 12px;
}
.daterangepicker .calendar-time {
text-align: center;
margin: 4px auto 0 auto;
line-height: 30px;
position: relative;
}
.daterangepicker .calendar-time select.disabled {
color: #ccc;
cursor: not-allowed;
}
.daterangepicker .drp-buttons {
clear: both;
text-align: right;
padding: 8px;
border-top: 1px solid #ddd;
display: none;
line-height: 12px;
vertical-align: middle;
}
.daterangepicker .drp-selected {
display: inline-block;
font-size: 12px;
padding-right: 8px;
}
.daterangepicker .drp-buttons .btn {
margin-left: 8px;
font-size: 12px;
font-weight: bold;
padding: 4px 8px;
}
.daterangepicker.show-ranges.single.rtl .drp-calendar.left {
border-right: 1px solid #ddd;
}
.daterangepicker.show-ranges.single.ltr .drp-calendar.left {
border-left: 1px solid #ddd;
}
.daterangepicker.show-ranges.rtl .drp-calendar.right {
border-right: 1px solid #ddd;
}
.daterangepicker.show-ranges.ltr .drp-calendar.left {
border-left: 1px solid #ddd;
}
.daterangepicker .ranges {
float: none;
text-align: left;
margin: 0;
}
.daterangepicker.show-calendar .ranges {
margin-top: 8px;
}
.daterangepicker .ranges ul {
list-style: none;
margin: 0 auto;
padding: 0;
width: 100%;
}
.daterangepicker .ranges li {
font-size: 12px;
padding: 8px 12px;
cursor: pointer;
}
.daterangepicker .ranges li:hover {
background-color: #eee;
}
.daterangepicker .ranges li.active {
background-color: #08c;
color: #fff;
}
/* Larger Screen Styling */
@media (min-width: 564px) {
.daterangepicker {
width: auto;
}
.daterangepicker .ranges ul {
width: 140px;
}
.daterangepicker.single .ranges ul {
width: 100%;
}
.daterangepicker.single .drp-calendar.left {
clear: none;
}
.daterangepicker.single .ranges, .daterangepicker.single .drp-calendar {
float: left;
}
.daterangepicker {
direction: ltr;
text-align: left;
}
.daterangepicker .drp-calendar.left {
clear: left;
margin-right: 0;
}
.daterangepicker .drp-calendar.left .calendar-table {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.daterangepicker .drp-calendar.right {
margin-left: 0;
}
.daterangepicker .drp-calendar.right .calendar-table {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.daterangepicker .drp-calendar.left .calendar-table {
padding-right: 8px;
}
.daterangepicker .ranges, .daterangepicker .drp-calendar {
float: left;
}
}
@media (min-width: 730px) {
.daterangepicker .ranges {
width: auto;
}
.daterangepicker .ranges {
float: left;
}
.daterangepicker.rtl .ranges {
float: right;
}
.daterangepicker .drp-calendar.left {
clear: none !important;
}
}

BIN
static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

BIN
static/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

7
static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
static/js/buttons.bootstrap5.min.js vendored Normal file
View File

@@ -0,0 +1,4 @@
/*! Bootstrap integration for DataTables' Buttons
* © SpryMedia Ltd - datatables.net/license
*/
!function(o){var e,a;"function"==typeof define&&define.amd?define(["jquery","datatables.net-bs5","datatables.net-buttons"],function(t){return o(t,window,document)}):"object"==typeof exports?(e=require("jquery"),a=function(t,n){n.fn.dataTable||require("datatables.net-bs5")(t,n),n.fn.dataTable.Buttons||require("datatables.net-buttons")(t,n)},"undefined"==typeof window?module.exports=function(t,n){return t=t||window,n=n||e(t),a(t,n),o(n,0,t.document)}:(a(window,e),module.exports=o(e,window,window.document))):o(jQuery,window,document)}(function(o,t,n,e){"use strict";var a=o.fn.dataTable;return o.extend(!0,a.Buttons.defaults,{dom:{container:{className:"dt-buttons btn-group flex-wrap"},button:{className:"btn btn-secondary",active:"active"},collection:{action:{dropHtml:""},container:{tag:"div",className:"dropdown-menu dt-button-collection"},closeButton:!1,button:{tag:"a",className:"dt-button dropdown-item",active:"dt-button-active",disabled:"disabled",spacer:{className:"dropdown-divider",tag:"hr"}}},split:{action:{tag:"a",className:"btn btn-secondary dt-button-split-drop-button",closeButton:!1},dropdown:{tag:"button",dropHtml:"",className:"btn btn-secondary dt-button-split-drop dropdown-toggle dropdown-toggle-split",closeButton:!1,align:"split-left",splitAlignClass:"dt-button-split-left"},wrapper:{tag:"div",className:"dt-button-split btn-group",closeButton:!1}}},buttonCreated:function(t,n){return t.buttons?o('<div class="btn-group"/>').append(n):n}}),a.ext.buttons.collection.className+=" dropdown-toggle",a.ext.buttons.collection.rightAlignClassName="dropdown-menu-right",a});

8
static/js/buttons.html5.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
static/js/buttons.print.min.js vendored Normal file
View File

@@ -0,0 +1,5 @@
/*!
* Print button for Buttons and DataTables.
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var o,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net","datatables.net-buttons"],function(t){return n(t,window,document)}):"object"==typeof exports?(o=require("jquery"),r=function(t,e){e.fn.dataTable||require("datatables.net")(t,e),e.fn.dataTable.Buttons||require("datatables.net-buttons")(t,e)},"undefined"==typeof window?module.exports=function(t,e){return t=t||window,e=e||o(t),r(t,e),n(e,t,t.document)}:(r(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(m,b,t,p){"use strict";function h(t){return n.href=t,-1===(t=n.host).indexOf("/")&&0!==n.pathname.indexOf("/")&&(t+="/"),n.protocol+"//"+t+n.pathname+n.search}var e=m.fn.dataTable,n=t.createElement("a");return e.ext.buttons.print={className:"buttons-print",text:function(t){return t.i18n("buttons.print","Print")},action:function(t,e,n,o){function r(t,e){for(var n="<tr>",o=0,r=t.length;o<r;o++){var i=null===t[o]||t[o]===p?"":t[o];n+="<"+e+" "+(s[o]?'class="'+s[o]+'"':"")+">"+i+"</"+e+">"}return n+"</tr>"}var i=e.buttons.exportData(m.extend({decodeEntities:!1},o.exportOptions)),a=e.buttons.exportInfo(o),s=e.columns(o.exportOptions.columns).flatten().map(function(t){return e.settings()[0].aoColumns[e.column(t).index()].sClass}).toArray(),u='<table class="'+e.table().node().className+'">';o.header&&(u+="<thead>"+r(i.header,"th")+"</thead>"),u+="<tbody>";for(var d=0,c=i.body.length;d<c;d++)u+=r(i.body[d],"td");u+="</tbody>",o.footer&&i.footer&&(u+="<tfoot>"+r(i.footer,"th")+"</tfoot>"),u+="</table>";var l=b.open("","");if(l){l.document.close();var f="<title>"+a.title+"</title>";m("style, link").each(function(){f+=function(t){t=m(t).clone()[0];return"link"===t.nodeName.toLowerCase()&&(t.href=h(t.href)),t.outerHTML}(this)});try{l.document.head.innerHTML=f}catch(t){m(l.document.head).html(f)}l.document.body.innerHTML="<h1>"+a.title+"</h1><div>"+(a.messageTop||"")+"</div>"+u+"<div>"+(a.messageBottom||"")+"</div>",m(l.document.body).addClass("dt-print-view"),m("img",l.document.body).each(function(t,e){e.setAttribute("src",h(e.getAttribute("src")))}),o.customize&&o.customize(l,o,e);a=function(){o.autoPrint&&(l.print(),l.close())};navigator.userAgent.match(/Trident\/\d.\d/)?a():l.setTimeout(a,1e3)}else e.buttons.info(e.i18n("buttons.printErrorTitle","Unable to open print view"),e.i18n("buttons.printErrorMsg","Please allow popups in your browser for this site to be able to view the print view."),5e3)},title:"*",messageTop:"*",messageBottom:"*",exportOptions:{},header:!0,footer:!1,autoPrint:!0,customize:null},e});

14
static/js/dataTables.bootstrap5.min.js vendored Normal file
View File

@@ -0,0 +1,14 @@
/*!
DataTables Bootstrap 5 integration
2020 SpryMedia Ltd - datatables.net/license
*/
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var e=a.length,d=0;d<e;d++){var f=a[d];if(b.call(c,f,d,a))return{i:d,v:f}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;$jscomp.ISOLATE_POLYFILLS=!1;
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(a,b,c){if(a==Array.prototype||a==Object.prototype)return a;a[b]=c.value;return a};$jscomp.getGlobal=function(a){a=["object"==typeof globalThis&&globalThis,a,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var b=0;b<a.length;++b){var c=a[b];if(c&&c.Math==Math)return c}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";var $jscomp$lookupPolyfilledValue=function(a,b){var c=$jscomp.propertyToPolyfillSymbol[b];if(null==c)return a[b];c=a[c];return void 0!==c?c:a[b]};
$jscomp.polyfill=function(a,b,c,e){b&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(a,b,c,e):$jscomp.polyfillUnisolated(a,b,c,e))};$jscomp.polyfillUnisolated=function(a,b,c,e){c=$jscomp.global;a=a.split(".");for(e=0;e<a.length-1;e++){var d=a[e];if(!(d in c))return;c=c[d]}a=a[a.length-1];e=c[a];b=b(e);b!=e&&null!=b&&$jscomp.defineProperty(c,a,{configurable:!0,writable:!0,value:b})};
$jscomp.polyfillIsolated=function(a,b,c,e){var d=a.split(".");a=1===d.length;e=d[0];e=!a&&e in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var f=0;f<d.length-1;f++){var l=d[f];if(!(l in e))return;e=e[l]}d=d[d.length-1];c=$jscomp.IS_SYMBOL_NATIVE&&"es6"===c?e[d]:null;b=b(c);null!=b&&(a?$jscomp.defineProperty($jscomp.polyfills,d,{configurable:!0,writable:!0,value:b}):b!==c&&($jscomp.propertyToPolyfillSymbol[d]=$jscomp.IS_SYMBOL_NATIVE?$jscomp.global.Symbol(d):$jscomp.POLYFILL_PREFIX+d,d=
$jscomp.propertyToPolyfillSymbol[d],$jscomp.defineProperty(e,d,{configurable:!0,writable:!0,value:b})))};$jscomp.polyfill("Array.prototype.find",function(a){return a?a:function(b,c){return $jscomp.findInternal(this,b,c).v}},"es6","es3");
(function(a){"function"===typeof define&&define.amd?define(["jquery","datatables.net"],function(b){return a(b,window,document)}):"object"===typeof exports?module.exports=function(b,c){b||(b=window);c&&c.fn.dataTable||(c=require("datatables.net")(b,c).$);return a(c,b,b.document)}:a(jQuery,window,document)})(function(a,b,c,e){var d=a.fn.dataTable;a.extend(!0,d.defaults,{dom:"<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
renderer:"bootstrap"});a.extend(d.ext.classes,{sWrapper:"dataTables_wrapper dt-bootstrap5",sFilterInput:"form-control form-control-sm",sLengthSelect:"form-select form-select-sm",sProcessing:"dataTables_processing card",sPageButton:"paginate_button page-item"});d.ext.renderer.pageButton.bootstrap=function(f,l,A,B,m,t){var u=new d.Api(f),C=f.oClasses,n=f.oLanguage.oPaginate,D=f.oLanguage.oAria.paginate||{},h,k,v=0,y=function(q,w){var x,E=function(p){p.preventDefault();a(p.currentTarget).hasClass("disabled")||
u.page()==p.data.action||u.page(p.data.action).draw("page")};var r=0;for(x=w.length;r<x;r++){var g=w[r];if(Array.isArray(g))y(q,g);else{k=h="";switch(g){case "ellipsis":h="&#x2026;";k="disabled";break;case "first":h=n.sFirst;k=g+(0<m?"":" disabled");break;case "previous":h=n.sPrevious;k=g+(0<m?"":" disabled");break;case "next":h=n.sNext;k=g+(m<t-1?"":" disabled");break;case "last":h=n.sLast;k=g+(m<t-1?"":" disabled");break;default:h=g+1,k=m===g?"active":""}if(h){var F=a("<li>",{"class":C.sPageButton+
" "+k,id:0===A&&"string"===typeof g?f.sTableId+"_"+g:null}).append(a("<a>",{href:"#","aria-controls":f.sTableId,"aria-label":D[g],"data-dt-idx":v,tabindex:f.iTabIndex,"class":"page-link"}).html(h)).appendTo(q);f.oApi._fnBindAction(F,{action:g},E);v++}}}};try{var z=a(l).find(c.activeElement).data("dt-idx")}catch(q){}y(a(l).empty().html('<ul class="pagination"/>').children("ul"),B);z!==e&&a(l).find("[data-dt-idx="+z+"]").trigger("focus")};return d});

4
static/js/dataTables.buttons.min.js vendored Normal file

File diff suppressed because one or more lines are too long

15
static/js/daterangepicker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
static/js/jquery-3.6.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

187
static/js/jquery.dataTables.min.js vendored Normal file
View File

@@ -0,0 +1,187 @@
/*!
Copyright 2008-2021 SpryMedia Ltd.
This source file is free software, available under the following license:
MIT license - http://datatables.net/license
This source file is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details.
For details please refer to: http://www.datatables.net
DataTables 1.11.5
©2008-2021 SpryMedia Ltd - datatables.net/license
*/
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(l,z,A){l instanceof String&&(l=String(l));for(var q=l.length,E=0;E<q;E++){var P=l[E];if(z.call(A,P,E,l))return{i:E,v:P}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;$jscomp.ISOLATE_POLYFILLS=!1;
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(l,z,A){if(l==Array.prototype||l==Object.prototype)return l;l[z]=A.value;return l};$jscomp.getGlobal=function(l){l=["object"==typeof globalThis&&globalThis,l,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var z=0;z<l.length;++z){var A=l[z];if(A&&A.Math==Math)return A}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";var $jscomp$lookupPolyfilledValue=function(l,z){var A=$jscomp.propertyToPolyfillSymbol[z];if(null==A)return l[z];A=l[A];return void 0!==A?A:l[z]};
$jscomp.polyfill=function(l,z,A,q){z&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(l,z,A,q):$jscomp.polyfillUnisolated(l,z,A,q))};$jscomp.polyfillUnisolated=function(l,z,A,q){A=$jscomp.global;l=l.split(".");for(q=0;q<l.length-1;q++){var E=l[q];if(!(E in A))return;A=A[E]}l=l[l.length-1];q=A[l];z=z(q);z!=q&&null!=z&&$jscomp.defineProperty(A,l,{configurable:!0,writable:!0,value:z})};
$jscomp.polyfillIsolated=function(l,z,A,q){var E=l.split(".");l=1===E.length;q=E[0];q=!l&&q in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var P=0;P<E.length-1;P++){var ma=E[P];if(!(ma in q))return;q=q[ma]}E=E[E.length-1];A=$jscomp.IS_SYMBOL_NATIVE&&"es6"===A?q[E]:null;z=z(A);null!=z&&(l?$jscomp.defineProperty($jscomp.polyfills,E,{configurable:!0,writable:!0,value:z}):z!==A&&($jscomp.propertyToPolyfillSymbol[E]=$jscomp.IS_SYMBOL_NATIVE?$jscomp.global.Symbol(E):$jscomp.POLYFILL_PREFIX+E,
E=$jscomp.propertyToPolyfillSymbol[E],$jscomp.defineProperty(q,E,{configurable:!0,writable:!0,value:z})))};$jscomp.polyfill("Array.prototype.find",function(l){return l?l:function(z,A){return $jscomp.findInternal(this,z,A).v}},"es6","es3");
(function(l){"function"===typeof define&&define.amd?define(["jquery"],function(z){return l(z,window,document)}):"object"===typeof exports?module.exports=function(z,A){z||(z=window);A||(A="undefined"!==typeof window?require("jquery"):require("jquery")(z));return l(A,z,z.document)}:window.DataTable=l(jQuery,window,document)})(function(l,z,A,q){function E(a){var b,c,d={};l.each(a,function(e,h){(b=e.match(/^([^A-Z]+?)([A-Z])/))&&-1!=="a aa ai ao as b fn i m o s ".indexOf(b[1]+" ")&&(c=e.replace(b[0],
b[2].toLowerCase()),d[c]=e,"o"===b[1]&&E(a[e]))});a._hungarianMap=d}function P(a,b,c){a._hungarianMap||E(a);var d;l.each(b,function(e,h){d=a._hungarianMap[e];d===q||!c&&b[d]!==q||("o"===d.charAt(0)?(b[d]||(b[d]={}),l.extend(!0,b[d],b[e]),P(a[d],b[d],c)):b[d]=b[e])})}function ma(a){var b=u.defaults.oLanguage,c=b.sDecimal;c&&Xa(c);if(a){var d=a.sZeroRecords;!a.sEmptyTable&&d&&"No data available in table"===b.sEmptyTable&&X(a,a,"sZeroRecords","sEmptyTable");!a.sLoadingRecords&&d&&"Loading..."===b.sLoadingRecords&&
X(a,a,"sZeroRecords","sLoadingRecords");a.sInfoThousands&&(a.sThousands=a.sInfoThousands);(a=a.sDecimal)&&c!==a&&Xa(a)}}function zb(a){S(a,"ordering","bSort");S(a,"orderMulti","bSortMulti");S(a,"orderClasses","bSortClasses");S(a,"orderCellsTop","bSortCellsTop");S(a,"order","aaSorting");S(a,"orderFixed","aaSortingFixed");S(a,"paging","bPaginate");S(a,"pagingType","sPaginationType");S(a,"pageLength","iDisplayLength");S(a,"searching","bFilter");"boolean"===typeof a.sScrollX&&(a.sScrollX=a.sScrollX?"100%":
"");"boolean"===typeof a.scrollX&&(a.scrollX=a.scrollX?"100%":"");if(a=a.aoSearchCols)for(var b=0,c=a.length;b<c;b++)a[b]&&P(u.models.oSearch,a[b])}function Ab(a){S(a,"orderable","bSortable");S(a,"orderData","aDataSort");S(a,"orderSequence","asSorting");S(a,"orderDataType","sortDataType");var b=a.aDataSort;"number"!==typeof b||Array.isArray(b)||(a.aDataSort=[b])}function Bb(a){if(!u.__browser){var b={};u.__browser=b;var c=l("<div/>").css({position:"fixed",top:0,left:-1*l(z).scrollLeft(),height:1,
width:1,overflow:"hidden"}).append(l("<div/>").css({position:"absolute",top:1,left:1,width:100,overflow:"scroll"}).append(l("<div/>").css({width:"100%",height:10}))).appendTo("body"),d=c.children(),e=d.children();b.barWidth=d[0].offsetWidth-d[0].clientWidth;b.bScrollOversize=100===e[0].offsetWidth&&100!==d[0].clientWidth;b.bScrollbarLeft=1!==Math.round(e.offset().left);b.bBounding=c[0].getBoundingClientRect().width?!0:!1;c.remove()}l.extend(a.oBrowser,u.__browser);a.oScroll.iBarWidth=u.__browser.barWidth}
function Cb(a,b,c,d,e,h){var f=!1;if(c!==q){var g=c;f=!0}for(;d!==e;)a.hasOwnProperty(d)&&(g=f?b(g,a[d],d,a):a[d],f=!0,d+=h);return g}function Ya(a,b){var c=u.defaults.column,d=a.aoColumns.length;c=l.extend({},u.models.oColumn,c,{nTh:b?b:A.createElement("th"),sTitle:c.sTitle?c.sTitle:b?b.innerHTML:"",aDataSort:c.aDataSort?c.aDataSort:[d],mData:c.mData?c.mData:d,idx:d});a.aoColumns.push(c);c=a.aoPreSearchCols;c[d]=l.extend({},u.models.oSearch,c[d]);Ga(a,d,l(b).data())}function Ga(a,b,c){b=a.aoColumns[b];
var d=a.oClasses,e=l(b.nTh);if(!b.sWidthOrig){b.sWidthOrig=e.attr("width")||null;var h=(e.attr("style")||"").match(/width:\s*(\d+[pxem%]+)/);h&&(b.sWidthOrig=h[1])}c!==q&&null!==c&&(Ab(c),P(u.defaults.column,c,!0),c.mDataProp===q||c.mData||(c.mData=c.mDataProp),c.sType&&(b._sManualType=c.sType),c.className&&!c.sClass&&(c.sClass=c.className),c.sClass&&e.addClass(c.sClass),l.extend(b,c),X(b,c,"sWidth","sWidthOrig"),c.iDataSort!==q&&(b.aDataSort=[c.iDataSort]),X(b,c,"aDataSort"));var f=b.mData,g=na(f),
k=b.mRender?na(b.mRender):null;c=function(m){return"string"===typeof m&&-1!==m.indexOf("@")};b._bAttrSrc=l.isPlainObject(f)&&(c(f.sort)||c(f.type)||c(f.filter));b._setter=null;b.fnGetData=function(m,n,p){var t=g(m,n,q,p);return k&&n?k(t,n,m,p):t};b.fnSetData=function(m,n,p){return ha(f)(m,n,p)};"number"!==typeof f&&(a._rowReadObject=!0);a.oFeatures.bSort||(b.bSortable=!1,e.addClass(d.sSortableNone));a=-1!==l.inArray("asc",b.asSorting);c=-1!==l.inArray("desc",b.asSorting);b.bSortable&&(a||c)?a&&!c?
(b.sSortingClass=d.sSortableAsc,b.sSortingClassJUI=d.sSortJUIAscAllowed):!a&&c?(b.sSortingClass=d.sSortableDesc,b.sSortingClassJUI=d.sSortJUIDescAllowed):(b.sSortingClass=d.sSortable,b.sSortingClassJUI=d.sSortJUI):(b.sSortingClass=d.sSortableNone,b.sSortingClassJUI="")}function sa(a){if(!1!==a.oFeatures.bAutoWidth){var b=a.aoColumns;Za(a);for(var c=0,d=b.length;c<d;c++)b[c].nTh.style.width=b[c].sWidth}b=a.oScroll;""===b.sY&&""===b.sX||Ha(a);F(a,null,"column-sizing",[a])}function ta(a,b){a=Ia(a,"bVisible");
return"number"===typeof a[b]?a[b]:null}function ua(a,b){a=Ia(a,"bVisible");b=l.inArray(b,a);return-1!==b?b:null}function oa(a){var b=0;l.each(a.aoColumns,function(c,d){d.bVisible&&"none"!==l(d.nTh).css("display")&&b++});return b}function Ia(a,b){var c=[];l.map(a.aoColumns,function(d,e){d[b]&&c.push(e)});return c}function $a(a){var b=a.aoColumns,c=a.aoData,d=u.ext.type.detect,e,h,f;var g=0;for(e=b.length;g<e;g++){var k=b[g];var m=[];if(!k.sType&&k._sManualType)k.sType=k._sManualType;else if(!k.sType){var n=
0;for(h=d.length;n<h;n++){var p=0;for(f=c.length;p<f;p++){m[p]===q&&(m[p]=T(a,p,g,"type"));var t=d[n](m[p],a);if(!t&&n!==d.length-1)break;if("html"===t&&!Z(m[p]))break}if(t){k.sType=t;break}}k.sType||(k.sType="string")}}}function Db(a,b,c,d){var e,h,f,g=a.aoColumns;if(b)for(e=b.length-1;0<=e;e--){var k=b[e];var m=k.targets!==q?k.targets:k.aTargets;Array.isArray(m)||(m=[m]);var n=0;for(h=m.length;n<h;n++)if("number"===typeof m[n]&&0<=m[n]){for(;g.length<=m[n];)Ya(a);d(m[n],k)}else if("number"===typeof m[n]&&
0>m[n])d(g.length+m[n],k);else if("string"===typeof m[n]){var p=0;for(f=g.length;p<f;p++)("_all"==m[n]||l(g[p].nTh).hasClass(m[n]))&&d(p,k)}}if(c)for(e=0,a=c.length;e<a;e++)d(e,c[e])}function ia(a,b,c,d){var e=a.aoData.length,h=l.extend(!0,{},u.models.oRow,{src:c?"dom":"data",idx:e});h._aData=b;a.aoData.push(h);for(var f=a.aoColumns,g=0,k=f.length;g<k;g++)f[g].sType=null;a.aiDisplayMaster.push(e);b=a.rowIdFn(b);b!==q&&(a.aIds[b]=h);!c&&a.oFeatures.bDeferRender||ab(a,e,c,d);return e}function Ja(a,
b){var c;b instanceof l||(b=l(b));return b.map(function(d,e){c=bb(a,e);return ia(a,c.data,e,c.cells)})}function T(a,b,c,d){"search"===d?d="filter":"order"===d&&(d="sort");var e=a.iDraw,h=a.aoColumns[c],f=a.aoData[b]._aData,g=h.sDefaultContent,k=h.fnGetData(f,d,{settings:a,row:b,col:c});if(k===q)return a.iDrawError!=e&&null===g&&(da(a,0,"Requested unknown parameter "+("function"==typeof h.mData?"{function}":"'"+h.mData+"'")+" for row "+b+", column "+c,4),a.iDrawError=e),g;if((k===f||null===k)&&null!==
g&&d!==q)k=g;else if("function"===typeof k)return k.call(f);if(null===k&&"display"===d)return"";"filter"===d&&(a=u.ext.type.search,a[h.sType]&&(k=a[h.sType](k)));return k}function Eb(a,b,c,d){a.aoColumns[c].fnSetData(a.aoData[b]._aData,d,{settings:a,row:b,col:c})}function cb(a){return l.map(a.match(/(\\.|[^\.])+/g)||[""],function(b){return b.replace(/\\\./g,".")})}function db(a){return U(a.aoData,"_aData")}function Ka(a){a.aoData.length=0;a.aiDisplayMaster.length=0;a.aiDisplay.length=0;a.aIds={}}
function La(a,b,c){for(var d=-1,e=0,h=a.length;e<h;e++)a[e]==b?d=e:a[e]>b&&a[e]--; -1!=d&&c===q&&a.splice(d,1)}function va(a,b,c,d){var e=a.aoData[b],h,f=function(k,m){for(;k.childNodes.length;)k.removeChild(k.firstChild);k.innerHTML=T(a,b,m,"display")};if("dom"!==c&&(c&&"auto"!==c||"dom"!==e.src)){var g=e.anCells;if(g)if(d!==q)f(g[d],d);else for(c=0,h=g.length;c<h;c++)f(g[c],c)}else e._aData=bb(a,e,d,d===q?q:e._aData).data;e._aSortData=null;e._aFilterData=null;f=a.aoColumns;if(d!==q)f[d].sType=null;
else{c=0;for(h=f.length;c<h;c++)f[c].sType=null;eb(a,e)}}function bb(a,b,c,d){var e=[],h=b.firstChild,f,g=0,k,m=a.aoColumns,n=a._rowReadObject;d=d!==q?d:n?{}:[];var p=function(x,w){if("string"===typeof x){var r=x.indexOf("@");-1!==r&&(r=x.substring(r+1),ha(x)(d,w.getAttribute(r)))}},t=function(x){if(c===q||c===g)f=m[g],k=x.innerHTML.trim(),f&&f._bAttrSrc?(ha(f.mData._)(d,k),p(f.mData.sort,x),p(f.mData.type,x),p(f.mData.filter,x)):n?(f._setter||(f._setter=ha(f.mData)),f._setter(d,k)):d[g]=k;g++};if(h)for(;h;){var v=
h.nodeName.toUpperCase();if("TD"==v||"TH"==v)t(h),e.push(h);h=h.nextSibling}else for(e=b.anCells,h=0,v=e.length;h<v;h++)t(e[h]);(b=b.firstChild?b:b.nTr)&&(b=b.getAttribute("id"))&&ha(a.rowId)(d,b);return{data:d,cells:e}}function ab(a,b,c,d){var e=a.aoData[b],h=e._aData,f=[],g,k;if(null===e.nTr){var m=c||A.createElement("tr");e.nTr=m;e.anCells=f;m._DT_RowIndex=b;eb(a,e);var n=0;for(g=a.aoColumns.length;n<g;n++){var p=a.aoColumns[n];e=(k=c?!1:!0)?A.createElement(p.sCellType):d[n];e._DT_CellIndex={row:b,
column:n};f.push(e);if(k||!(!p.mRender&&p.mData===n||l.isPlainObject(p.mData)&&p.mData._===n+".display"))e.innerHTML=T(a,b,n,"display");p.sClass&&(e.className+=" "+p.sClass);p.bVisible&&!c?m.appendChild(e):!p.bVisible&&c&&e.parentNode.removeChild(e);p.fnCreatedCell&&p.fnCreatedCell.call(a.oInstance,e,T(a,b,n),h,b,n)}F(a,"aoRowCreatedCallback",null,[m,h,b,f])}}function eb(a,b){var c=b.nTr,d=b._aData;if(c){if(a=a.rowIdFn(d))c.id=a;d.DT_RowClass&&(a=d.DT_RowClass.split(" "),b.__rowc=b.__rowc?Ma(b.__rowc.concat(a)):
a,l(c).removeClass(b.__rowc.join(" ")).addClass(d.DT_RowClass));d.DT_RowAttr&&l(c).attr(d.DT_RowAttr);d.DT_RowData&&l(c).data(d.DT_RowData)}}function Fb(a){var b,c,d=a.nTHead,e=a.nTFoot,h=0===l("th, td",d).length,f=a.oClasses,g=a.aoColumns;h&&(c=l("<tr/>").appendTo(d));var k=0;for(b=g.length;k<b;k++){var m=g[k];var n=l(m.nTh).addClass(m.sClass);h&&n.appendTo(c);a.oFeatures.bSort&&(n.addClass(m.sSortingClass),!1!==m.bSortable&&(n.attr("tabindex",a.iTabIndex).attr("aria-controls",a.sTableId),fb(a,m.nTh,
k)));m.sTitle!=n[0].innerHTML&&n.html(m.sTitle);gb(a,"header")(a,n,m,f)}h&&wa(a.aoHeader,d);l(d).children("tr").children("th, td").addClass(f.sHeaderTH);l(e).children("tr").children("th, td").addClass(f.sFooterTH);if(null!==e)for(a=a.aoFooter[0],k=0,b=a.length;k<b;k++)m=g[k],m.nTf=a[k].cell,m.sClass&&l(m.nTf).addClass(m.sClass)}function xa(a,b,c){var d,e,h=[],f=[],g=a.aoColumns.length;if(b){c===q&&(c=!1);var k=0;for(d=b.length;k<d;k++){h[k]=b[k].slice();h[k].nTr=b[k].nTr;for(e=g-1;0<=e;e--)a.aoColumns[e].bVisible||
c||h[k].splice(e,1);f.push([])}k=0;for(d=h.length;k<d;k++){if(a=h[k].nTr)for(;e=a.firstChild;)a.removeChild(e);e=0;for(b=h[k].length;e<b;e++){var m=g=1;if(f[k][e]===q){a.appendChild(h[k][e].cell);for(f[k][e]=1;h[k+g]!==q&&h[k][e].cell==h[k+g][e].cell;)f[k+g][e]=1,g++;for(;h[k][e+m]!==q&&h[k][e].cell==h[k][e+m].cell;){for(c=0;c<g;c++)f[k+c][e+m]=1;m++}l(h[k][e].cell).attr("rowspan",g).attr("colspan",m)}}}}}function ja(a,b){var c="ssp"==Q(a),d=a.iInitDisplayStart;d!==q&&-1!==d&&(a._iDisplayStart=c?
d:d>=a.fnRecordsDisplay()?0:d,a.iInitDisplayStart=-1);c=F(a,"aoPreDrawCallback","preDraw",[a]);if(-1!==l.inArray(!1,c))V(a,!1);else{c=[];var e=0;d=a.asStripeClasses;var h=d.length,f=a.oLanguage,g="ssp"==Q(a),k=a.aiDisplay,m=a._iDisplayStart,n=a.fnDisplayEnd();a.bDrawing=!0;if(a.bDeferLoading)a.bDeferLoading=!1,a.iDraw++,V(a,!1);else if(!g)a.iDraw++;else if(!a.bDestroying&&!b){Gb(a);return}if(0!==k.length)for(b=g?a.aoData.length:n,f=g?0:m;f<b;f++){g=k[f];var p=a.aoData[g];null===p.nTr&&ab(a,g);var t=
p.nTr;if(0!==h){var v=d[e%h];p._sRowStripe!=v&&(l(t).removeClass(p._sRowStripe).addClass(v),p._sRowStripe=v)}F(a,"aoRowCallback",null,[t,p._aData,e,f,g]);c.push(t);e++}else e=f.sZeroRecords,1==a.iDraw&&"ajax"==Q(a)?e=f.sLoadingRecords:f.sEmptyTable&&0===a.fnRecordsTotal()&&(e=f.sEmptyTable),c[0]=l("<tr/>",{"class":h?d[0]:""}).append(l("<td />",{valign:"top",colSpan:oa(a),"class":a.oClasses.sRowEmpty}).html(e))[0];F(a,"aoHeaderCallback","header",[l(a.nTHead).children("tr")[0],db(a),m,n,k]);F(a,"aoFooterCallback",
"footer",[l(a.nTFoot).children("tr")[0],db(a),m,n,k]);d=l(a.nTBody);d.children().detach();d.append(l(c));F(a,"aoDrawCallback","draw",[a]);a.bSorted=!1;a.bFiltered=!1;a.bDrawing=!1}}function ka(a,b){var c=a.oFeatures,d=c.bFilter;c.bSort&&Hb(a);d?ya(a,a.oPreviousSearch):a.aiDisplay=a.aiDisplayMaster.slice();!0!==b&&(a._iDisplayStart=0);a._drawHold=b;ja(a);a._drawHold=!1}function Ib(a){var b=a.oClasses,c=l(a.nTable);c=l("<div/>").insertBefore(c);var d=a.oFeatures,e=l("<div/>",{id:a.sTableId+"_wrapper",
"class":b.sWrapper+(a.nTFoot?"":" "+b.sNoFooter)});a.nHolding=c[0];a.nTableWrapper=e[0];a.nTableReinsertBefore=a.nTable.nextSibling;for(var h=a.sDom.split(""),f,g,k,m,n,p,t=0;t<h.length;t++){f=null;g=h[t];if("<"==g){k=l("<div/>")[0];m=h[t+1];if("'"==m||'"'==m){n="";for(p=2;h[t+p]!=m;)n+=h[t+p],p++;"H"==n?n=b.sJUIHeader:"F"==n&&(n=b.sJUIFooter);-1!=n.indexOf(".")?(m=n.split("."),k.id=m[0].substr(1,m[0].length-1),k.className=m[1]):"#"==n.charAt(0)?k.id=n.substr(1,n.length-1):k.className=n;t+=p}e.append(k);
e=l(k)}else if(">"==g)e=e.parent();else if("l"==g&&d.bPaginate&&d.bLengthChange)f=Jb(a);else if("f"==g&&d.bFilter)f=Kb(a);else if("r"==g&&d.bProcessing)f=Lb(a);else if("t"==g)f=Mb(a);else if("i"==g&&d.bInfo)f=Nb(a);else if("p"==g&&d.bPaginate)f=Ob(a);else if(0!==u.ext.feature.length)for(k=u.ext.feature,p=0,m=k.length;p<m;p++)if(g==k[p].cFeature){f=k[p].fnInit(a);break}f&&(k=a.aanFeatures,k[g]||(k[g]=[]),k[g].push(f),e.append(f))}c.replaceWith(e);a.nHolding=null}function wa(a,b){b=l(b).children("tr");
var c,d,e;a.splice(0,a.length);var h=0;for(e=b.length;h<e;h++)a.push([]);h=0;for(e=b.length;h<e;h++){var f=b[h];for(c=f.firstChild;c;){if("TD"==c.nodeName.toUpperCase()||"TH"==c.nodeName.toUpperCase()){var g=1*c.getAttribute("colspan");var k=1*c.getAttribute("rowspan");g=g&&0!==g&&1!==g?g:1;k=k&&0!==k&&1!==k?k:1;var m=0;for(d=a[h];d[m];)m++;var n=m;var p=1===g?!0:!1;for(d=0;d<g;d++)for(m=0;m<k;m++)a[h+m][n+d]={cell:c,unique:p},a[h+m].nTr=f}c=c.nextSibling}}}function Na(a,b,c){var d=[];c||(c=a.aoHeader,
b&&(c=[],wa(c,b)));b=0;for(var e=c.length;b<e;b++)for(var h=0,f=c[b].length;h<f;h++)!c[b][h].unique||d[h]&&a.bSortCellsTop||(d[h]=c[b][h].cell);return d}function Oa(a,b,c){F(a,"aoServerParams","serverParams",[b]);if(b&&Array.isArray(b)){var d={},e=/(.*?)\[\]$/;l.each(b,function(n,p){(n=p.name.match(e))?(n=n[0],d[n]||(d[n]=[]),d[n].push(p.value)):d[p.name]=p.value});b=d}var h=a.ajax,f=a.oInstance,g=function(n){var p=a.jqXHR?a.jqXHR.status:null;if(null===n||"number"===typeof p&&204==p)n={},za(a,n,[]);
(p=n.error||n.sError)&&da(a,0,p);a.json=n;F(a,null,"xhr",[a,n,a.jqXHR]);c(n)};if(l.isPlainObject(h)&&h.data){var k=h.data;var m="function"===typeof k?k(b,a):k;b="function"===typeof k&&m?m:l.extend(!0,b,m);delete h.data}m={data:b,success:g,dataType:"json",cache:!1,type:a.sServerMethod,error:function(n,p,t){t=F(a,null,"xhr",[a,null,a.jqXHR]);-1===l.inArray(!0,t)&&("parsererror"==p?da(a,0,"Invalid JSON response",1):4===n.readyState&&da(a,0,"Ajax error",7));V(a,!1)}};a.oAjaxData=b;F(a,null,"preXhr",[a,
b]);a.fnServerData?a.fnServerData.call(f,a.sAjaxSource,l.map(b,function(n,p){return{name:p,value:n}}),g,a):a.sAjaxSource||"string"===typeof h?a.jqXHR=l.ajax(l.extend(m,{url:h||a.sAjaxSource})):"function"===typeof h?a.jqXHR=h.call(f,b,g,a):(a.jqXHR=l.ajax(l.extend(m,h)),h.data=k)}function Gb(a){a.iDraw++;V(a,!0);Oa(a,Pb(a),function(b){Qb(a,b)})}function Pb(a){var b=a.aoColumns,c=b.length,d=a.oFeatures,e=a.oPreviousSearch,h=a.aoPreSearchCols,f=[],g=pa(a);var k=a._iDisplayStart;var m=!1!==d.bPaginate?
a._iDisplayLength:-1;var n=function(x,w){f.push({name:x,value:w})};n("sEcho",a.iDraw);n("iColumns",c);n("sColumns",U(b,"sName").join(","));n("iDisplayStart",k);n("iDisplayLength",m);var p={draw:a.iDraw,columns:[],order:[],start:k,length:m,search:{value:e.sSearch,regex:e.bRegex}};for(k=0;k<c;k++){var t=b[k];var v=h[k];m="function"==typeof t.mData?"function":t.mData;p.columns.push({data:m,name:t.sName,searchable:t.bSearchable,orderable:t.bSortable,search:{value:v.sSearch,regex:v.bRegex}});n("mDataProp_"+
k,m);d.bFilter&&(n("sSearch_"+k,v.sSearch),n("bRegex_"+k,v.bRegex),n("bSearchable_"+k,t.bSearchable));d.bSort&&n("bSortable_"+k,t.bSortable)}d.bFilter&&(n("sSearch",e.sSearch),n("bRegex",e.bRegex));d.bSort&&(l.each(g,function(x,w){p.order.push({column:w.col,dir:w.dir});n("iSortCol_"+x,w.col);n("sSortDir_"+x,w.dir)}),n("iSortingCols",g.length));b=u.ext.legacy.ajax;return null===b?a.sAjaxSource?f:p:b?f:p}function Qb(a,b){var c=function(f,g){return b[f]!==q?b[f]:b[g]},d=za(a,b),e=c("sEcho","draw"),h=
c("iTotalRecords","recordsTotal");c=c("iTotalDisplayRecords","recordsFiltered");if(e!==q){if(1*e<a.iDraw)return;a.iDraw=1*e}d||(d=[]);Ka(a);a._iRecordsTotal=parseInt(h,10);a._iRecordsDisplay=parseInt(c,10);e=0;for(h=d.length;e<h;e++)ia(a,d[e]);a.aiDisplay=a.aiDisplayMaster.slice();ja(a,!0);a._bInitComplete||Pa(a,b);V(a,!1)}function za(a,b,c){a=l.isPlainObject(a.ajax)&&a.ajax.dataSrc!==q?a.ajax.dataSrc:a.sAjaxDataProp;if(!c)return"data"===a?b.aaData||b[a]:""!==a?na(a)(b):b;ha(a)(b,c)}function Kb(a){var b=
a.oClasses,c=a.sTableId,d=a.oLanguage,e=a.oPreviousSearch,h=a.aanFeatures,f='<input type="search" class="'+b.sFilterInput+'"/>',g=d.sSearch;g=g.match(/_INPUT_/)?g.replace("_INPUT_",f):g+f;b=l("<div/>",{id:h.f?null:c+"_filter","class":b.sFilter}).append(l("<label/>").append(g));var k=function(n){var p=this.value?this.value:"";e.return&&"Enter"!==n.key||p==e.sSearch||(ya(a,{sSearch:p,bRegex:e.bRegex,bSmart:e.bSmart,bCaseInsensitive:e.bCaseInsensitive,"return":e.return}),a._iDisplayStart=0,ja(a))};h=
null!==a.searchDelay?a.searchDelay:"ssp"===Q(a)?400:0;var m=l("input",b).val(e.sSearch).attr("placeholder",d.sSearchPlaceholder).on("keyup.DT search.DT input.DT paste.DT cut.DT",h?hb(k,h):k).on("mouseup",function(n){setTimeout(function(){k.call(m[0],n)},10)}).on("keypress.DT",function(n){if(13==n.keyCode)return!1}).attr("aria-controls",c);l(a.nTable).on("search.dt.DT",function(n,p){if(a===p)try{m[0]!==A.activeElement&&m.val(e.sSearch)}catch(t){}});return b[0]}function ya(a,b,c){var d=a.oPreviousSearch,
e=a.aoPreSearchCols,h=function(g){d.sSearch=g.sSearch;d.bRegex=g.bRegex;d.bSmart=g.bSmart;d.bCaseInsensitive=g.bCaseInsensitive;d.return=g.return},f=function(g){return g.bEscapeRegex!==q?!g.bEscapeRegex:g.bRegex};$a(a);if("ssp"!=Q(a)){Rb(a,b.sSearch,c,f(b),b.bSmart,b.bCaseInsensitive,b.return);h(b);for(b=0;b<e.length;b++)Sb(a,e[b].sSearch,b,f(e[b]),e[b].bSmart,e[b].bCaseInsensitive);Tb(a)}else h(b);a.bFiltered=!0;F(a,null,"search",[a])}function Tb(a){for(var b=u.ext.search,c=a.aiDisplay,d,e,h=0,f=
b.length;h<f;h++){for(var g=[],k=0,m=c.length;k<m;k++)e=c[k],d=a.aoData[e],b[h](a,d._aFilterData,e,d._aData,k)&&g.push(e);c.length=0;l.merge(c,g)}}function Sb(a,b,c,d,e,h){if(""!==b){var f=[],g=a.aiDisplay;d=ib(b,d,e,h);for(e=0;e<g.length;e++)b=a.aoData[g[e]]._aFilterData[c],d.test(b)&&f.push(g[e]);a.aiDisplay=f}}function Rb(a,b,c,d,e,h){e=ib(b,d,e,h);var f=a.oPreviousSearch.sSearch,g=a.aiDisplayMaster;h=[];0!==u.ext.search.length&&(c=!0);var k=Ub(a);if(0>=b.length)a.aiDisplay=g.slice();else{if(k||
c||d||f.length>b.length||0!==b.indexOf(f)||a.bSorted)a.aiDisplay=g.slice();b=a.aiDisplay;for(c=0;c<b.length;c++)e.test(a.aoData[b[c]]._sFilterRow)&&h.push(b[c]);a.aiDisplay=h}}function ib(a,b,c,d){a=b?a:jb(a);c&&(a="^(?=.*?"+l.map(a.match(/"[^"]+"|[^ ]+/g)||[""],function(e){if('"'===e.charAt(0)){var h=e.match(/^"(.*)"$/);e=h?h[1]:e}return e.replace('"',"")}).join(")(?=.*?")+").*$");return new RegExp(a,d?"i":"")}function Ub(a){var b=a.aoColumns,c,d;var e=!1;var h=0;for(c=a.aoData.length;h<c;h++){var f=
a.aoData[h];if(!f._aFilterData){var g=[];e=0;for(d=b.length;e<d;e++){var k=b[e];k.bSearchable?(k=T(a,h,e,"filter"),null===k&&(k=""),"string"!==typeof k&&k.toString&&(k=k.toString())):k="";k.indexOf&&-1!==k.indexOf("&")&&(Qa.innerHTML=k,k=tc?Qa.textContent:Qa.innerText);k.replace&&(k=k.replace(/[\r\n\u2028]/g,""));g.push(k)}f._aFilterData=g;f._sFilterRow=g.join(" ");e=!0}}return e}function Vb(a){return{search:a.sSearch,smart:a.bSmart,regex:a.bRegex,caseInsensitive:a.bCaseInsensitive}}function Wb(a){return{sSearch:a.search,
bSmart:a.smart,bRegex:a.regex,bCaseInsensitive:a.caseInsensitive}}function Nb(a){var b=a.sTableId,c=a.aanFeatures.i,d=l("<div/>",{"class":a.oClasses.sInfo,id:c?null:b+"_info"});c||(a.aoDrawCallback.push({fn:Xb,sName:"information"}),d.attr("role","status").attr("aria-live","polite"),l(a.nTable).attr("aria-describedby",b+"_info"));return d[0]}function Xb(a){var b=a.aanFeatures.i;if(0!==b.length){var c=a.oLanguage,d=a._iDisplayStart+1,e=a.fnDisplayEnd(),h=a.fnRecordsTotal(),f=a.fnRecordsDisplay(),g=
f?c.sInfo:c.sInfoEmpty;f!==h&&(g+=" "+c.sInfoFiltered);g+=c.sInfoPostFix;g=Yb(a,g);c=c.fnInfoCallback;null!==c&&(g=c.call(a.oInstance,a,d,e,h,f,g));l(b).html(g)}}function Yb(a,b){var c=a.fnFormatNumber,d=a._iDisplayStart+1,e=a._iDisplayLength,h=a.fnRecordsDisplay(),f=-1===e;return b.replace(/_START_/g,c.call(a,d)).replace(/_END_/g,c.call(a,a.fnDisplayEnd())).replace(/_MAX_/g,c.call(a,a.fnRecordsTotal())).replace(/_TOTAL_/g,c.call(a,h)).replace(/_PAGE_/g,c.call(a,f?1:Math.ceil(d/e))).replace(/_PAGES_/g,
c.call(a,f?1:Math.ceil(h/e)))}function Aa(a){var b=a.iInitDisplayStart,c=a.aoColumns;var d=a.oFeatures;var e=a.bDeferLoading;if(a.bInitialised){Ib(a);Fb(a);xa(a,a.aoHeader);xa(a,a.aoFooter);V(a,!0);d.bAutoWidth&&Za(a);var h=0;for(d=c.length;h<d;h++){var f=c[h];f.sWidth&&(f.nTh.style.width=K(f.sWidth))}F(a,null,"preInit",[a]);ka(a);c=Q(a);if("ssp"!=c||e)"ajax"==c?Oa(a,[],function(g){var k=za(a,g);for(h=0;h<k.length;h++)ia(a,k[h]);a.iInitDisplayStart=b;ka(a);V(a,!1);Pa(a,g)},a):(V(a,!1),Pa(a))}else setTimeout(function(){Aa(a)},
200)}function Pa(a,b){a._bInitComplete=!0;(b||a.oInit.aaData)&&sa(a);F(a,null,"plugin-init",[a,b]);F(a,"aoInitComplete","init",[a,b])}function kb(a,b){b=parseInt(b,10);a._iDisplayLength=b;lb(a);F(a,null,"length",[a,b])}function Jb(a){var b=a.oClasses,c=a.sTableId,d=a.aLengthMenu,e=Array.isArray(d[0]),h=e?d[0]:d;d=e?d[1]:d;e=l("<select/>",{name:c+"_length","aria-controls":c,"class":b.sLengthSelect});for(var f=0,g=h.length;f<g;f++)e[0][f]=new Option("number"===typeof d[f]?a.fnFormatNumber(d[f]):d[f],
h[f]);var k=l("<div><label/></div>").addClass(b.sLength);a.aanFeatures.l||(k[0].id=c+"_length");k.children().append(a.oLanguage.sLengthMenu.replace("_MENU_",e[0].outerHTML));l("select",k).val(a._iDisplayLength).on("change.DT",function(m){kb(a,l(this).val());ja(a)});l(a.nTable).on("length.dt.DT",function(m,n,p){a===n&&l("select",k).val(p)});return k[0]}function Ob(a){var b=a.sPaginationType,c=u.ext.pager[b],d="function"===typeof c,e=function(f){ja(f)};b=l("<div/>").addClass(a.oClasses.sPaging+b)[0];
var h=a.aanFeatures;d||c.fnInit(a,b,e);h.p||(b.id=a.sTableId+"_paginate",a.aoDrawCallback.push({fn:function(f){if(d){var g=f._iDisplayStart,k=f._iDisplayLength,m=f.fnRecordsDisplay(),n=-1===k;g=n?0:Math.ceil(g/k);k=n?1:Math.ceil(m/k);m=c(g,k);var p;n=0;for(p=h.p.length;n<p;n++)gb(f,"pageButton")(f,h.p[n],n,m,g,k)}else c.fnUpdate(f,e)},sName:"pagination"}));return b}function Ra(a,b,c){var d=a._iDisplayStart,e=a._iDisplayLength,h=a.fnRecordsDisplay();0===h||-1===e?d=0:"number"===typeof b?(d=b*e,d>h&&
(d=0)):"first"==b?d=0:"previous"==b?(d=0<=e?d-e:0,0>d&&(d=0)):"next"==b?d+e<h&&(d+=e):"last"==b?d=Math.floor((h-1)/e)*e:da(a,0,"Unknown paging action: "+b,5);b=a._iDisplayStart!==d;a._iDisplayStart=d;b&&(F(a,null,"page",[a]),c&&ja(a));return b}function Lb(a){return l("<div/>",{id:a.aanFeatures.r?null:a.sTableId+"_processing","class":a.oClasses.sProcessing}).html(a.oLanguage.sProcessing).insertBefore(a.nTable)[0]}function V(a,b){a.oFeatures.bProcessing&&l(a.aanFeatures.r).css("display",b?"block":"none");
F(a,null,"processing",[a,b])}function Mb(a){var b=l(a.nTable),c=a.oScroll;if(""===c.sX&&""===c.sY)return a.nTable;var d=c.sX,e=c.sY,h=a.oClasses,f=b.children("caption"),g=f.length?f[0]._captionSide:null,k=l(b[0].cloneNode(!1)),m=l(b[0].cloneNode(!1)),n=b.children("tfoot");n.length||(n=null);k=l("<div/>",{"class":h.sScrollWrapper}).append(l("<div/>",{"class":h.sScrollHead}).css({overflow:"hidden",position:"relative",border:0,width:d?d?K(d):null:"100%"}).append(l("<div/>",{"class":h.sScrollHeadInner}).css({"box-sizing":"content-box",
width:c.sXInner||"100%"}).append(k.removeAttr("id").css("margin-left",0).append("top"===g?f:null).append(b.children("thead"))))).append(l("<div/>",{"class":h.sScrollBody}).css({position:"relative",overflow:"auto",width:d?K(d):null}).append(b));n&&k.append(l("<div/>",{"class":h.sScrollFoot}).css({overflow:"hidden",border:0,width:d?d?K(d):null:"100%"}).append(l("<div/>",{"class":h.sScrollFootInner}).append(m.removeAttr("id").css("margin-left",0).append("bottom"===g?f:null).append(b.children("tfoot")))));
b=k.children();var p=b[0];h=b[1];var t=n?b[2]:null;if(d)l(h).on("scroll.DT",function(v){v=this.scrollLeft;p.scrollLeft=v;n&&(t.scrollLeft=v)});l(h).css("max-height",e);c.bCollapse||l(h).css("height",e);a.nScrollHead=p;a.nScrollBody=h;a.nScrollFoot=t;a.aoDrawCallback.push({fn:Ha,sName:"scrolling"});return k[0]}function Ha(a){var b=a.oScroll,c=b.sX,d=b.sXInner,e=b.sY;b=b.iBarWidth;var h=l(a.nScrollHead),f=h[0].style,g=h.children("div"),k=g[0].style,m=g.children("table");g=a.nScrollBody;var n=l(g),p=
g.style,t=l(a.nScrollFoot).children("div"),v=t.children("table"),x=l(a.nTHead),w=l(a.nTable),r=w[0],C=r.style,G=a.nTFoot?l(a.nTFoot):null,aa=a.oBrowser,L=aa.bScrollOversize;U(a.aoColumns,"nTh");var O=[],I=[],H=[],ea=[],Y,Ba=function(D){D=D.style;D.paddingTop="0";D.paddingBottom="0";D.borderTopWidth="0";D.borderBottomWidth="0";D.height=0};var fa=g.scrollHeight>g.clientHeight;if(a.scrollBarVis!==fa&&a.scrollBarVis!==q)a.scrollBarVis=fa,sa(a);else{a.scrollBarVis=fa;w.children("thead, tfoot").remove();
if(G){var ba=G.clone().prependTo(w);var la=G.find("tr");ba=ba.find("tr")}var mb=x.clone().prependTo(w);x=x.find("tr");fa=mb.find("tr");mb.find("th, td").removeAttr("tabindex");c||(p.width="100%",h[0].style.width="100%");l.each(Na(a,mb),function(D,W){Y=ta(a,D);W.style.width=a.aoColumns[Y].sWidth});G&&ca(function(D){D.style.width=""},ba);h=w.outerWidth();""===c?(C.width="100%",L&&(w.find("tbody").height()>g.offsetHeight||"scroll"==n.css("overflow-y"))&&(C.width=K(w.outerWidth()-b)),h=w.outerWidth()):
""!==d&&(C.width=K(d),h=w.outerWidth());ca(Ba,fa);ca(function(D){var W=z.getComputedStyle?z.getComputedStyle(D).width:K(l(D).width());H.push(D.innerHTML);O.push(W)},fa);ca(function(D,W){D.style.width=O[W]},x);l(fa).css("height",0);G&&(ca(Ba,ba),ca(function(D){ea.push(D.innerHTML);I.push(K(l(D).css("width")))},ba),ca(function(D,W){D.style.width=I[W]},la),l(ba).height(0));ca(function(D,W){D.innerHTML='<div class="dataTables_sizing">'+H[W]+"</div>";D.childNodes[0].style.height="0";D.childNodes[0].style.overflow=
"hidden";D.style.width=O[W]},fa);G&&ca(function(D,W){D.innerHTML='<div class="dataTables_sizing">'+ea[W]+"</div>";D.childNodes[0].style.height="0";D.childNodes[0].style.overflow="hidden";D.style.width=I[W]},ba);Math.round(w.outerWidth())<Math.round(h)?(la=g.scrollHeight>g.offsetHeight||"scroll"==n.css("overflow-y")?h+b:h,L&&(g.scrollHeight>g.offsetHeight||"scroll"==n.css("overflow-y"))&&(C.width=K(la-b)),""!==c&&""===d||da(a,1,"Possible column misalignment",6)):la="100%";p.width=K(la);f.width=K(la);
G&&(a.nScrollFoot.style.width=K(la));!e&&L&&(p.height=K(r.offsetHeight+b));c=w.outerWidth();m[0].style.width=K(c);k.width=K(c);d=w.height()>g.clientHeight||"scroll"==n.css("overflow-y");e="padding"+(aa.bScrollbarLeft?"Left":"Right");k[e]=d?b+"px":"0px";G&&(v[0].style.width=K(c),t[0].style.width=K(c),t[0].style[e]=d?b+"px":"0px");w.children("colgroup").insertBefore(w.children("thead"));n.trigger("scroll");!a.bSorted&&!a.bFiltered||a._drawHold||(g.scrollTop=0)}}function ca(a,b,c){for(var d=0,e=0,h=
b.length,f,g;e<h;){f=b[e].firstChild;for(g=c?c[e].firstChild:null;f;)1===f.nodeType&&(c?a(f,g,d):a(f,d),d++),f=f.nextSibling,g=c?g.nextSibling:null;e++}}function Za(a){var b=a.nTable,c=a.aoColumns,d=a.oScroll,e=d.sY,h=d.sX,f=d.sXInner,g=c.length,k=Ia(a,"bVisible"),m=l("th",a.nTHead),n=b.getAttribute("width"),p=b.parentNode,t=!1,v,x=a.oBrowser;d=x.bScrollOversize;(v=b.style.width)&&-1!==v.indexOf("%")&&(n=v);for(v=0;v<k.length;v++){var w=c[k[v]];null!==w.sWidth&&(w.sWidth=Zb(w.sWidthOrig,p),t=!0)}if(d||
!t&&!h&&!e&&g==oa(a)&&g==m.length)for(v=0;v<g;v++)k=ta(a,v),null!==k&&(c[k].sWidth=K(m.eq(v).width()));else{g=l(b).clone().css("visibility","hidden").removeAttr("id");g.find("tbody tr").remove();var r=l("<tr/>").appendTo(g.find("tbody"));g.find("thead, tfoot").remove();g.append(l(a.nTHead).clone()).append(l(a.nTFoot).clone());g.find("tfoot th, tfoot td").css("width","");m=Na(a,g.find("thead")[0]);for(v=0;v<k.length;v++)w=c[k[v]],m[v].style.width=null!==w.sWidthOrig&&""!==w.sWidthOrig?K(w.sWidthOrig):
"",w.sWidthOrig&&h&&l(m[v]).append(l("<div/>").css({width:w.sWidthOrig,margin:0,padding:0,border:0,height:1}));if(a.aoData.length)for(v=0;v<k.length;v++)t=k[v],w=c[t],l($b(a,t)).clone(!1).append(w.sContentPadding).appendTo(r);l("[name]",g).removeAttr("name");w=l("<div/>").css(h||e?{position:"absolute",top:0,left:0,height:1,right:0,overflow:"hidden"}:{}).append(g).appendTo(p);h&&f?g.width(f):h?(g.css("width","auto"),g.removeAttr("width"),g.width()<p.clientWidth&&n&&g.width(p.clientWidth)):e?g.width(p.clientWidth):
n&&g.width(n);for(v=e=0;v<k.length;v++)p=l(m[v]),f=p.outerWidth()-p.width(),p=x.bBounding?Math.ceil(m[v].getBoundingClientRect().width):p.outerWidth(),e+=p,c[k[v]].sWidth=K(p-f);b.style.width=K(e);w.remove()}n&&(b.style.width=K(n));!n&&!h||a._reszEvt||(b=function(){l(z).on("resize.DT-"+a.sInstance,hb(function(){sa(a)}))},d?setTimeout(b,1E3):b(),a._reszEvt=!0)}function Zb(a,b){if(!a)return 0;a=l("<div/>").css("width",K(a)).appendTo(b||A.body);b=a[0].offsetWidth;a.remove();return b}function $b(a,b){var c=
ac(a,b);if(0>c)return null;var d=a.aoData[c];return d.nTr?d.anCells[b]:l("<td/>").html(T(a,c,b,"display"))[0]}function ac(a,b){for(var c,d=-1,e=-1,h=0,f=a.aoData.length;h<f;h++)c=T(a,h,b,"display")+"",c=c.replace(uc,""),c=c.replace(/&nbsp;/g," "),c.length>d&&(d=c.length,e=h);return e}function K(a){return null===a?"0px":"number"==typeof a?0>a?"0px":a+"px":a.match(/\d$/)?a+"px":a}function pa(a){var b=[],c=a.aoColumns;var d=a.aaSortingFixed;var e=l.isPlainObject(d);var h=[];var f=function(n){n.length&&
!Array.isArray(n[0])?h.push(n):l.merge(h,n)};Array.isArray(d)&&f(d);e&&d.pre&&f(d.pre);f(a.aaSorting);e&&d.post&&f(d.post);for(a=0;a<h.length;a++){var g=h[a][0];f=c[g].aDataSort;d=0;for(e=f.length;d<e;d++){var k=f[d];var m=c[k].sType||"string";h[a]._idx===q&&(h[a]._idx=l.inArray(h[a][1],c[k].asSorting));b.push({src:g,col:k,dir:h[a][1],index:h[a]._idx,type:m,formatter:u.ext.type.order[m+"-pre"]})}}return b}function Hb(a){var b,c=[],d=u.ext.type.order,e=a.aoData,h=0,f=a.aiDisplayMaster;$a(a);var g=
pa(a);var k=0;for(b=g.length;k<b;k++){var m=g[k];m.formatter&&h++;bc(a,m.col)}if("ssp"!=Q(a)&&0!==g.length){k=0;for(b=f.length;k<b;k++)c[f[k]]=k;h===g.length?f.sort(function(n,p){var t,v=g.length,x=e[n]._aSortData,w=e[p]._aSortData;for(t=0;t<v;t++){var r=g[t];var C=x[r.col];var G=w[r.col];C=C<G?-1:C>G?1:0;if(0!==C)return"asc"===r.dir?C:-C}C=c[n];G=c[p];return C<G?-1:C>G?1:0}):f.sort(function(n,p){var t,v=g.length,x=e[n]._aSortData,w=e[p]._aSortData;for(t=0;t<v;t++){var r=g[t];var C=x[r.col];var G=
w[r.col];r=d[r.type+"-"+r.dir]||d["string-"+r.dir];C=r(C,G);if(0!==C)return C}C=c[n];G=c[p];return C<G?-1:C>G?1:0})}a.bSorted=!0}function cc(a){var b=a.aoColumns,c=pa(a);a=a.oLanguage.oAria;for(var d=0,e=b.length;d<e;d++){var h=b[d];var f=h.asSorting;var g=h.ariaTitle||h.sTitle.replace(/<.*?>/g,"");var k=h.nTh;k.removeAttribute("aria-sort");h.bSortable&&(0<c.length&&c[0].col==d?(k.setAttribute("aria-sort","asc"==c[0].dir?"ascending":"descending"),h=f[c[0].index+1]||f[0]):h=f[0],g+="asc"===h?a.sSortAscending:
a.sSortDescending);k.setAttribute("aria-label",g)}}function nb(a,b,c,d){var e=a.aaSorting,h=a.aoColumns[b].asSorting,f=function(g,k){var m=g._idx;m===q&&(m=l.inArray(g[1],h));return m+1<h.length?m+1:k?null:0};"number"===typeof e[0]&&(e=a.aaSorting=[e]);c&&a.oFeatures.bSortMulti?(c=l.inArray(b,U(e,"0")),-1!==c?(b=f(e[c],!0),null===b&&1===e.length&&(b=0),null===b?e.splice(c,1):(e[c][1]=h[b],e[c]._idx=b)):(e.push([b,h[0],0]),e[e.length-1]._idx=0)):e.length&&e[0][0]==b?(b=f(e[0]),e.length=1,e[0][1]=h[b],
e[0]._idx=b):(e.length=0,e.push([b,h[0]]),e[0]._idx=0);ka(a);"function"==typeof d&&d(a)}function fb(a,b,c,d){var e=a.aoColumns[c];ob(b,{},function(h){!1!==e.bSortable&&(a.oFeatures.bProcessing?(V(a,!0),setTimeout(function(){nb(a,c,h.shiftKey,d);"ssp"!==Q(a)&&V(a,!1)},0)):nb(a,c,h.shiftKey,d))})}function Sa(a){var b=a.aLastSort,c=a.oClasses.sSortColumn,d=pa(a),e=a.oFeatures,h;if(e.bSort&&e.bSortClasses){e=0;for(h=b.length;e<h;e++){var f=b[e].src;l(U(a.aoData,"anCells",f)).removeClass(c+(2>e?e+1:3))}e=
0;for(h=d.length;e<h;e++)f=d[e].src,l(U(a.aoData,"anCells",f)).addClass(c+(2>e?e+1:3))}a.aLastSort=d}function bc(a,b){var c=a.aoColumns[b],d=u.ext.order[c.sSortDataType],e;d&&(e=d.call(a.oInstance,a,b,ua(a,b)));for(var h,f=u.ext.type.order[c.sType+"-pre"],g=0,k=a.aoData.length;g<k;g++)if(c=a.aoData[g],c._aSortData||(c._aSortData=[]),!c._aSortData[b]||d)h=d?e[g]:T(a,g,b,"sort"),c._aSortData[b]=f?f(h):h}function Ca(a){if(!a._bLoadingState){var b={time:+new Date,start:a._iDisplayStart,length:a._iDisplayLength,
order:l.extend(!0,[],a.aaSorting),search:Vb(a.oPreviousSearch),columns:l.map(a.aoColumns,function(c,d){return{visible:c.bVisible,search:Vb(a.aoPreSearchCols[d])}})};a.oSavedState=b;F(a,"aoStateSaveParams","stateSaveParams",[a,b]);a.oFeatures.bStateSave&&!a.bDestroying&&a.fnStateSaveCallback.call(a.oInstance,a,b)}}function dc(a,b,c){if(a.oFeatures.bStateSave)return b=a.fnStateLoadCallback.call(a.oInstance,a,function(d){pb(a,d,c)}),b!==q&&pb(a,b,c),!0;c()}function pb(a,b,c){var d,e=a.aoColumns;a._bLoadingState=
!0;var h=a._bInitComplete?new u.Api(a):null;if(b&&b.time){var f=F(a,"aoStateLoadParams","stateLoadParams",[a,b]);if(-1!==l.inArray(!1,f))a._bLoadingState=!1;else if(f=a.iStateDuration,0<f&&b.time<+new Date-1E3*f)a._bLoadingState=!1;else if(b.columns&&e.length!==b.columns.length)a._bLoadingState=!1;else{a.oLoadedState=l.extend(!0,{},b);b.start!==q&&(null===h?(a._iDisplayStart=b.start,a.iInitDisplayStart=b.start):Ra(a,b.start/b.length));b.length!==q&&(a._iDisplayLength=b.length);b.order!==q&&(a.aaSorting=
[],l.each(b.order,function(k,m){a.aaSorting.push(m[0]>=e.length?[0,m[1]]:m)}));b.search!==q&&l.extend(a.oPreviousSearch,Wb(b.search));if(b.columns){f=0;for(d=b.columns.length;f<d;f++){var g=b.columns[f];g.visible!==q&&(h?h.column(f).visible(g.visible,!1):e[f].bVisible=g.visible);g.search!==q&&l.extend(a.aoPreSearchCols[f],Wb(g.search))}h&&h.columns.adjust()}a._bLoadingState=!1;F(a,"aoStateLoaded","stateLoaded",[a,b])}}else a._bLoadingState=!1;c()}function Ta(a){var b=u.settings;a=l.inArray(a,U(b,
"nTable"));return-1!==a?b[a]:null}function da(a,b,c,d){c="DataTables warning: "+(a?"table id="+a.sTableId+" - ":"")+c;d&&(c+=". For more information about this error, please see http://datatables.net/tn/"+d);if(b)z.console&&console.log&&console.log(c);else if(b=u.ext,b=b.sErrMode||b.errMode,a&&F(a,null,"error",[a,d,c]),"alert"==b)alert(c);else{if("throw"==b)throw Error(c);"function"==typeof b&&b(a,d,c)}}function X(a,b,c,d){Array.isArray(c)?l.each(c,function(e,h){Array.isArray(h)?X(a,b,h[0],h[1]):
X(a,b,h)}):(d===q&&(d=c),b[c]!==q&&(a[d]=b[c]))}function qb(a,b,c){var d;for(d in b)if(b.hasOwnProperty(d)){var e=b[d];l.isPlainObject(e)?(l.isPlainObject(a[d])||(a[d]={}),l.extend(!0,a[d],e)):c&&"data"!==d&&"aaData"!==d&&Array.isArray(e)?a[d]=e.slice():a[d]=e}return a}function ob(a,b,c){l(a).on("click.DT",b,function(d){l(a).trigger("blur");c(d)}).on("keypress.DT",b,function(d){13===d.which&&(d.preventDefault(),c(d))}).on("selectstart.DT",function(){return!1})}function R(a,b,c,d){c&&a[b].push({fn:c,
sName:d})}function F(a,b,c,d){var e=[];b&&(e=l.map(a[b].slice().reverse(),function(h,f){return h.fn.apply(a.oInstance,d)}));null!==c&&(b=l.Event(c+".dt"),l(a.nTable).trigger(b,d),e.push(b.result));return e}function lb(a){var b=a._iDisplayStart,c=a.fnDisplayEnd(),d=a._iDisplayLength;b>=c&&(b=c-d);b-=b%d;if(-1===d||0>b)b=0;a._iDisplayStart=b}function gb(a,b){a=a.renderer;var c=u.ext.renderer[b];return l.isPlainObject(a)&&a[b]?c[a[b]]||c._:"string"===typeof a?c[a]||c._:c._}function Q(a){return a.oFeatures.bServerSide?
"ssp":a.ajax||a.sAjaxSource?"ajax":"dom"}function Da(a,b){var c=ec.numbers_length,d=Math.floor(c/2);b<=c?a=qa(0,b):a<=d?(a=qa(0,c-2),a.push("ellipsis"),a.push(b-1)):(a>=b-1-d?a=qa(b-(c-2),b):(a=qa(a-d+2,a+d-1),a.push("ellipsis"),a.push(b-1)),a.splice(0,0,"ellipsis"),a.splice(0,0,0));a.DT_el="span";return a}function Xa(a){l.each({num:function(b){return Ua(b,a)},"num-fmt":function(b){return Ua(b,a,rb)},"html-num":function(b){return Ua(b,a,Va)},"html-num-fmt":function(b){return Ua(b,a,Va,rb)}},function(b,
c){M.type.order[b+a+"-pre"]=c;b.match(/^html\-/)&&(M.type.search[b+a]=M.type.search.html)})}function fc(a){return function(){var b=[Ta(this[u.ext.iApiIndex])].concat(Array.prototype.slice.call(arguments));return u.ext.internal[a].apply(this,b)}}var u=function(a,b){if(this instanceof u)return l(a).DataTable(b);b=a;this.$=function(f,g){return this.api(!0).$(f,g)};this._=function(f,g){return this.api(!0).rows(f,g).data()};this.api=function(f){return f?new B(Ta(this[M.iApiIndex])):new B(this)};this.fnAddData=
function(f,g){var k=this.api(!0);f=Array.isArray(f)&&(Array.isArray(f[0])||l.isPlainObject(f[0]))?k.rows.add(f):k.row.add(f);(g===q||g)&&k.draw();return f.flatten().toArray()};this.fnAdjustColumnSizing=function(f){var g=this.api(!0).columns.adjust(),k=g.settings()[0],m=k.oScroll;f===q||f?g.draw(!1):(""!==m.sX||""!==m.sY)&&Ha(k)};this.fnClearTable=function(f){var g=this.api(!0).clear();(f===q||f)&&g.draw()};this.fnClose=function(f){this.api(!0).row(f).child.hide()};this.fnDeleteRow=function(f,g,k){var m=
this.api(!0);f=m.rows(f);var n=f.settings()[0],p=n.aoData[f[0][0]];f.remove();g&&g.call(this,n,p);(k===q||k)&&m.draw();return p};this.fnDestroy=function(f){this.api(!0).destroy(f)};this.fnDraw=function(f){this.api(!0).draw(f)};this.fnFilter=function(f,g,k,m,n,p){n=this.api(!0);null===g||g===q?n.search(f,k,m,p):n.column(g).search(f,k,m,p);n.draw()};this.fnGetData=function(f,g){var k=this.api(!0);if(f!==q){var m=f.nodeName?f.nodeName.toLowerCase():"";return g!==q||"td"==m||"th"==m?k.cell(f,g).data():
k.row(f).data()||null}return k.data().toArray()};this.fnGetNodes=function(f){var g=this.api(!0);return f!==q?g.row(f).node():g.rows().nodes().flatten().toArray()};this.fnGetPosition=function(f){var g=this.api(!0),k=f.nodeName.toUpperCase();return"TR"==k?g.row(f).index():"TD"==k||"TH"==k?(f=g.cell(f).index(),[f.row,f.columnVisible,f.column]):null};this.fnIsOpen=function(f){return this.api(!0).row(f).child.isShown()};this.fnOpen=function(f,g,k){return this.api(!0).row(f).child(g,k).show().child()[0]};
this.fnPageChange=function(f,g){f=this.api(!0).page(f);(g===q||g)&&f.draw(!1)};this.fnSetColumnVis=function(f,g,k){f=this.api(!0).column(f).visible(g);(k===q||k)&&f.columns.adjust().draw()};this.fnSettings=function(){return Ta(this[M.iApiIndex])};this.fnSort=function(f){this.api(!0).order(f).draw()};this.fnSortListener=function(f,g,k){this.api(!0).order.listener(f,g,k)};this.fnUpdate=function(f,g,k,m,n){var p=this.api(!0);k===q||null===k?p.row(g).data(f):p.cell(g,k).data(f);(n===q||n)&&p.columns.adjust();
(m===q||m)&&p.draw();return 0};this.fnVersionCheck=M.fnVersionCheck;var c=this,d=b===q,e=this.length;d&&(b={});this.oApi=this.internal=M.internal;for(var h in u.ext.internal)h&&(this[h]=fc(h));this.each(function(){var f={},g=1<e?qb(f,b,!0):b,k=0,m;f=this.getAttribute("id");var n=!1,p=u.defaults,t=l(this);if("table"!=this.nodeName.toLowerCase())da(null,0,"Non-table node initialisation ("+this.nodeName+")",2);else{zb(p);Ab(p.column);P(p,p,!0);P(p.column,p.column,!0);P(p,l.extend(g,t.data()),!0);var v=
u.settings;k=0;for(m=v.length;k<m;k++){var x=v[k];if(x.nTable==this||x.nTHead&&x.nTHead.parentNode==this||x.nTFoot&&x.nTFoot.parentNode==this){var w=g.bRetrieve!==q?g.bRetrieve:p.bRetrieve;if(d||w)return x.oInstance;if(g.bDestroy!==q?g.bDestroy:p.bDestroy){x.oInstance.fnDestroy();break}else{da(x,0,"Cannot reinitialise DataTable",3);return}}if(x.sTableId==this.id){v.splice(k,1);break}}if(null===f||""===f)this.id=f="DataTables_Table_"+u.ext._unique++;var r=l.extend(!0,{},u.models.oSettings,{sDestroyWidth:t[0].style.width,
sInstance:f,sTableId:f});r.nTable=this;r.oApi=c.internal;r.oInit=g;v.push(r);r.oInstance=1===c.length?c:t.dataTable();zb(g);ma(g.oLanguage);g.aLengthMenu&&!g.iDisplayLength&&(g.iDisplayLength=Array.isArray(g.aLengthMenu[0])?g.aLengthMenu[0][0]:g.aLengthMenu[0]);g=qb(l.extend(!0,{},p),g);X(r.oFeatures,g,"bPaginate bLengthChange bFilter bSort bSortMulti bInfo bProcessing bAutoWidth bSortClasses bServerSide bDeferRender".split(" "));X(r,g,["asStripeClasses","ajax","fnServerData","fnFormatNumber","sServerMethod",
"aaSorting","aaSortingFixed","aLengthMenu","sPaginationType","sAjaxSource","sAjaxDataProp","iStateDuration","sDom","bSortCellsTop","iTabIndex","fnStateLoadCallback","fnStateSaveCallback","renderer","searchDelay","rowId",["iCookieDuration","iStateDuration"],["oSearch","oPreviousSearch"],["aoSearchCols","aoPreSearchCols"],["iDisplayLength","_iDisplayLength"]]);X(r.oScroll,g,[["sScrollX","sX"],["sScrollXInner","sXInner"],["sScrollY","sY"],["bScrollCollapse","bCollapse"]]);X(r.oLanguage,g,"fnInfoCallback");
R(r,"aoDrawCallback",g.fnDrawCallback,"user");R(r,"aoServerParams",g.fnServerParams,"user");R(r,"aoStateSaveParams",g.fnStateSaveParams,"user");R(r,"aoStateLoadParams",g.fnStateLoadParams,"user");R(r,"aoStateLoaded",g.fnStateLoaded,"user");R(r,"aoRowCallback",g.fnRowCallback,"user");R(r,"aoRowCreatedCallback",g.fnCreatedRow,"user");R(r,"aoHeaderCallback",g.fnHeaderCallback,"user");R(r,"aoFooterCallback",g.fnFooterCallback,"user");R(r,"aoInitComplete",g.fnInitComplete,"user");R(r,"aoPreDrawCallback",
g.fnPreDrawCallback,"user");r.rowIdFn=na(g.rowId);Bb(r);var C=r.oClasses;l.extend(C,u.ext.classes,g.oClasses);t.addClass(C.sTable);r.iInitDisplayStart===q&&(r.iInitDisplayStart=g.iDisplayStart,r._iDisplayStart=g.iDisplayStart);null!==g.iDeferLoading&&(r.bDeferLoading=!0,f=Array.isArray(g.iDeferLoading),r._iRecordsDisplay=f?g.iDeferLoading[0]:g.iDeferLoading,r._iRecordsTotal=f?g.iDeferLoading[1]:g.iDeferLoading);var G=r.oLanguage;l.extend(!0,G,g.oLanguage);G.sUrl?(l.ajax({dataType:"json",url:G.sUrl,
success:function(I){P(p.oLanguage,I);ma(I);l.extend(!0,G,I);F(r,null,"i18n",[r]);Aa(r)},error:function(){Aa(r)}}),n=!0):F(r,null,"i18n",[r]);null===g.asStripeClasses&&(r.asStripeClasses=[C.sStripeOdd,C.sStripeEven]);f=r.asStripeClasses;var aa=t.children("tbody").find("tr").eq(0);-1!==l.inArray(!0,l.map(f,function(I,H){return aa.hasClass(I)}))&&(l("tbody tr",this).removeClass(f.join(" ")),r.asDestroyStripes=f.slice());f=[];v=this.getElementsByTagName("thead");0!==v.length&&(wa(r.aoHeader,v[0]),f=Na(r));
if(null===g.aoColumns)for(v=[],k=0,m=f.length;k<m;k++)v.push(null);else v=g.aoColumns;k=0;for(m=v.length;k<m;k++)Ya(r,f?f[k]:null);Db(r,g.aoColumnDefs,v,function(I,H){Ga(r,I,H)});if(aa.length){var L=function(I,H){return null!==I.getAttribute("data-"+H)?H:null};l(aa[0]).children("th, td").each(function(I,H){var ea=r.aoColumns[I];if(ea.mData===I){var Y=L(H,"sort")||L(H,"order");H=L(H,"filter")||L(H,"search");if(null!==Y||null!==H)ea.mData={_:I+".display",sort:null!==Y?I+".@data-"+Y:q,type:null!==Y?
I+".@data-"+Y:q,filter:null!==H?I+".@data-"+H:q},Ga(r,I)}})}var O=r.oFeatures;f=function(){if(g.aaSorting===q){var I=r.aaSorting;k=0;for(m=I.length;k<m;k++)I[k][1]=r.aoColumns[k].asSorting[0]}Sa(r);O.bSort&&R(r,"aoDrawCallback",function(){if(r.bSorted){var Y=pa(r),Ba={};l.each(Y,function(fa,ba){Ba[ba.src]=ba.dir});F(r,null,"order",[r,Y,Ba]);cc(r)}});R(r,"aoDrawCallback",function(){(r.bSorted||"ssp"===Q(r)||O.bDeferRender)&&Sa(r)},"sc");I=t.children("caption").each(function(){this._captionSide=l(this).css("caption-side")});
var H=t.children("thead");0===H.length&&(H=l("<thead/>").appendTo(t));r.nTHead=H[0];var ea=t.children("tbody");0===ea.length&&(ea=l("<tbody/>").insertAfter(H));r.nTBody=ea[0];H=t.children("tfoot");0===H.length&&0<I.length&&(""!==r.oScroll.sX||""!==r.oScroll.sY)&&(H=l("<tfoot/>").appendTo(t));0===H.length||0===H.children().length?t.addClass(C.sNoFooter):0<H.length&&(r.nTFoot=H[0],wa(r.aoFooter,r.nTFoot));if(g.aaData)for(k=0;k<g.aaData.length;k++)ia(r,g.aaData[k]);else(r.bDeferLoading||"dom"==Q(r))&&
Ja(r,l(r.nTBody).children("tr"));r.aiDisplay=r.aiDisplayMaster.slice();r.bInitialised=!0;!1===n&&Aa(r)};R(r,"aoDrawCallback",Ca,"state_save");g.bStateSave?(O.bStateSave=!0,dc(r,g,f)):f()}});c=null;return this},M,y,J,sb={},gc=/[\r\n\u2028]/g,Va=/<.*?>/g,vc=/^\d{2,4}[\.\/\-]\d{1,2}[\.\/\-]\d{1,2}([T ]{1}\d{1,2}[:\.]\d{2}([\.:]\d{2})?)?$/,wc=/(\/|\.|\*|\+|\?|\||\(|\)|\[|\]|\{|\}|\\|\$|\^|\-)/g,rb=/['\u00A0,$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfkɃΞ]/gi,Z=function(a){return a&&!0!==a&&"-"!==a?!1:!0},hc=
function(a){var b=parseInt(a,10);return!isNaN(b)&&isFinite(a)?b:null},ic=function(a,b){sb[b]||(sb[b]=new RegExp(jb(b),"g"));return"string"===typeof a&&"."!==b?a.replace(/\./g,"").replace(sb[b],"."):a},tb=function(a,b,c){var d="string"===typeof a;if(Z(a))return!0;b&&d&&(a=ic(a,b));c&&d&&(a=a.replace(rb,""));return!isNaN(parseFloat(a))&&isFinite(a)},jc=function(a,b,c){return Z(a)?!0:Z(a)||"string"===typeof a?tb(a.replace(Va,""),b,c)?!0:null:null},U=function(a,b,c){var d=[],e=0,h=a.length;if(c!==q)for(;e<
h;e++)a[e]&&a[e][b]&&d.push(a[e][b][c]);else for(;e<h;e++)a[e]&&d.push(a[e][b]);return d},Ea=function(a,b,c,d){var e=[],h=0,f=b.length;if(d!==q)for(;h<f;h++)a[b[h]][c]&&e.push(a[b[h]][c][d]);else for(;h<f;h++)e.push(a[b[h]][c]);return e},qa=function(a,b){var c=[];if(b===q){b=0;var d=a}else d=b,b=a;for(a=b;a<d;a++)c.push(a);return c},kc=function(a){for(var b=[],c=0,d=a.length;c<d;c++)a[c]&&b.push(a[c]);return b},Ma=function(a){a:{if(!(2>a.length)){var b=a.slice().sort();for(var c=b[0],d=1,e=b.length;d<
e;d++){if(b[d]===c){b=!1;break a}c=b[d]}}b=!0}if(b)return a.slice();b=[];e=a.length;var h,f=0;d=0;a:for(;d<e;d++){c=a[d];for(h=0;h<f;h++)if(b[h]===c)continue a;b.push(c);f++}return b},lc=function(a,b){if(Array.isArray(b))for(var c=0;c<b.length;c++)lc(a,b[c]);else a.push(b);return a},mc=function(a,b){b===q&&(b=0);return-1!==this.indexOf(a,b)};Array.isArray||(Array.isArray=function(a){return"[object Array]"===Object.prototype.toString.call(a)});Array.prototype.includes||(Array.prototype.includes=mc);
String.prototype.trim||(String.prototype.trim=function(){return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")});String.prototype.includes||(String.prototype.includes=mc);u.util={throttle:function(a,b){var c=b!==q?b:200,d,e;return function(){var h=this,f=+new Date,g=arguments;d&&f<d+c?(clearTimeout(e),e=setTimeout(function(){d=q;a.apply(h,g)},c)):(d=f,a.apply(h,g))}},escapeRegex:function(a){return a.replace(wc,"\\$1")},set:function(a){if(l.isPlainObject(a))return u.util.set(a._);if(null===
a)return function(){};if("function"===typeof a)return function(c,d,e){a(c,"set",d,e)};if("string"!==typeof a||-1===a.indexOf(".")&&-1===a.indexOf("[")&&-1===a.indexOf("("))return function(c,d){c[a]=d};var b=function(c,d,e){e=cb(e);var h=e[e.length-1];for(var f,g,k=0,m=e.length-1;k<m;k++){if("__proto__"===e[k]||"constructor"===e[k])throw Error("Cannot set prototype values");f=e[k].match(Fa);g=e[k].match(ra);if(f){e[k]=e[k].replace(Fa,"");c[e[k]]=[];h=e.slice();h.splice(0,k+1);f=h.join(".");if(Array.isArray(d))for(g=
0,m=d.length;g<m;g++)h={},b(h,d[g],f),c[e[k]].push(h);else c[e[k]]=d;return}g&&(e[k]=e[k].replace(ra,""),c=c[e[k]](d));if(null===c[e[k]]||c[e[k]]===q)c[e[k]]={};c=c[e[k]]}if(h.match(ra))c[h.replace(ra,"")](d);else c[h.replace(Fa,"")]=d};return function(c,d){return b(c,d,a)}},get:function(a){if(l.isPlainObject(a)){var b={};l.each(a,function(d,e){e&&(b[d]=u.util.get(e))});return function(d,e,h,f){var g=b[e]||b._;return g!==q?g(d,e,h,f):d}}if(null===a)return function(d){return d};if("function"===typeof a)return function(d,
e,h,f){return a(d,e,h,f)};if("string"!==typeof a||-1===a.indexOf(".")&&-1===a.indexOf("[")&&-1===a.indexOf("("))return function(d,e){return d[a]};var c=function(d,e,h){if(""!==h){var f=cb(h);for(var g=0,k=f.length;g<k;g++){h=f[g].match(Fa);var m=f[g].match(ra);if(h){f[g]=f[g].replace(Fa,"");""!==f[g]&&(d=d[f[g]]);m=[];f.splice(0,g+1);f=f.join(".");if(Array.isArray(d))for(g=0,k=d.length;g<k;g++)m.push(c(d[g],e,f));d=h[0].substring(1,h[0].length-1);d=""===d?m:m.join(d);break}else if(m){f[g]=f[g].replace(ra,
"");d=d[f[g]]();continue}if(null===d||d[f[g]]===q)return q;d=d[f[g]]}}return d};return function(d,e){return c(d,e,a)}}};var S=function(a,b,c){a[b]!==q&&(a[c]=a[b])},Fa=/\[.*?\]$/,ra=/\(\)$/,na=u.util.get,ha=u.util.set,jb=u.util.escapeRegex,Qa=l("<div>")[0],tc=Qa.textContent!==q,uc=/<.*?>/g,hb=u.util.throttle,nc=[],N=Array.prototype,xc=function(a){var b,c=u.settings,d=l.map(c,function(h,f){return h.nTable});if(a){if(a.nTable&&a.oApi)return[a];if(a.nodeName&&"table"===a.nodeName.toLowerCase()){var e=
l.inArray(a,d);return-1!==e?[c[e]]:null}if(a&&"function"===typeof a.settings)return a.settings().toArray();"string"===typeof a?b=l(a):a instanceof l&&(b=a)}else return[];if(b)return b.map(function(h){e=l.inArray(this,d);return-1!==e?c[e]:null}).toArray()};var B=function(a,b){if(!(this instanceof B))return new B(a,b);var c=[],d=function(f){(f=xc(f))&&c.push.apply(c,f)};if(Array.isArray(a))for(var e=0,h=a.length;e<h;e++)d(a[e]);else d(a);this.context=Ma(c);b&&l.merge(this,b);this.selector={rows:null,
cols:null,opts:null};B.extend(this,this,nc)};u.Api=B;l.extend(B.prototype,{any:function(){return 0!==this.count()},concat:N.concat,context:[],count:function(){return this.flatten().length},each:function(a){for(var b=0,c=this.length;b<c;b++)a.call(this,this[b],b,this);return this},eq:function(a){var b=this.context;return b.length>a?new B(b[a],this[a]):null},filter:function(a){var b=[];if(N.filter)b=N.filter.call(this,a,this);else for(var c=0,d=this.length;c<d;c++)a.call(this,this[c],c,this)&&b.push(this[c]);
return new B(this.context,b)},flatten:function(){var a=[];return new B(this.context,a.concat.apply(a,this.toArray()))},join:N.join,indexOf:N.indexOf||function(a,b){b=b||0;for(var c=this.length;b<c;b++)if(this[b]===a)return b;return-1},iterator:function(a,b,c,d){var e=[],h,f,g=this.context,k,m=this.selector;"string"===typeof a&&(d=c,c=b,b=a,a=!1);var n=0;for(h=g.length;n<h;n++){var p=new B(g[n]);if("table"===b){var t=c.call(p,g[n],n);t!==q&&e.push(t)}else if("columns"===b||"rows"===b)t=c.call(p,g[n],
this[n],n),t!==q&&e.push(t);else if("column"===b||"column-rows"===b||"row"===b||"cell"===b){var v=this[n];"column-rows"===b&&(k=Wa(g[n],m.opts));var x=0;for(f=v.length;x<f;x++)t=v[x],t="cell"===b?c.call(p,g[n],t.row,t.column,n,x):c.call(p,g[n],t,n,x,k),t!==q&&e.push(t)}}return e.length||d?(a=new B(g,a?e.concat.apply([],e):e),b=a.selector,b.rows=m.rows,b.cols=m.cols,b.opts=m.opts,a):this},lastIndexOf:N.lastIndexOf||function(a,b){return this.indexOf.apply(this.toArray.reverse(),arguments)},length:0,
map:function(a){var b=[];if(N.map)b=N.map.call(this,a,this);else for(var c=0,d=this.length;c<d;c++)b.push(a.call(this,this[c],c));return new B(this.context,b)},pluck:function(a){return this.map(function(b){return b[a]})},pop:N.pop,push:N.push,reduce:N.reduce||function(a,b){return Cb(this,a,b,0,this.length,1)},reduceRight:N.reduceRight||function(a,b){return Cb(this,a,b,this.length-1,-1,-1)},reverse:N.reverse,selector:null,shift:N.shift,slice:function(){return new B(this.context,this)},sort:N.sort,
splice:N.splice,toArray:function(){return N.slice.call(this)},to$:function(){return l(this)},toJQuery:function(){return l(this)},unique:function(){return new B(this.context,Ma(this))},unshift:N.unshift});B.extend=function(a,b,c){if(c.length&&b&&(b instanceof B||b.__dt_wrapper)){var d,e=function(g,k,m){return function(){var n=k.apply(g,arguments);B.extend(n,n,m.methodExt);return n}};var h=0;for(d=c.length;h<d;h++){var f=c[h];b[f.name]="function"===f.type?e(a,f.val,f):"object"===f.type?{}:f.val;b[f.name].__dt_wrapper=
!0;B.extend(a,b[f.name],f.propExt)}}};B.register=y=function(a,b){if(Array.isArray(a))for(var c=0,d=a.length;c<d;c++)B.register(a[c],b);else{d=a.split(".");var e=nc,h;a=0;for(c=d.length;a<c;a++){var f=(h=-1!==d[a].indexOf("()"))?d[a].replace("()",""):d[a];a:{var g=0;for(var k=e.length;g<k;g++)if(e[g].name===f){g=e[g];break a}g=null}g||(g={name:f,val:{},methodExt:[],propExt:[],type:"object"},e.push(g));a===c-1?(g.val=b,g.type="function"===typeof b?"function":l.isPlainObject(b)?"object":"other"):e=h?
g.methodExt:g.propExt}}};B.registerPlural=J=function(a,b,c){B.register(a,c);B.register(b,function(){var d=c.apply(this,arguments);return d===this?this:d instanceof B?d.length?Array.isArray(d[0])?new B(d.context,d[0]):d[0]:q:d})};var oc=function(a,b){if(Array.isArray(a))return l.map(a,function(d){return oc(d,b)});if("number"===typeof a)return[b[a]];var c=l.map(b,function(d,e){return d.nTable});return l(c).filter(a).map(function(d){d=l.inArray(this,c);return b[d]}).toArray()};y("tables()",function(a){return a!==
q&&null!==a?new B(oc(a,this.context)):this});y("table()",function(a){a=this.tables(a);var b=a.context;return b.length?new B(b[0]):a});J("tables().nodes()","table().node()",function(){return this.iterator("table",function(a){return a.nTable},1)});J("tables().body()","table().body()",function(){return this.iterator("table",function(a){return a.nTBody},1)});J("tables().header()","table().header()",function(){return this.iterator("table",function(a){return a.nTHead},1)});J("tables().footer()","table().footer()",
function(){return this.iterator("table",function(a){return a.nTFoot},1)});J("tables().containers()","table().container()",function(){return this.iterator("table",function(a){return a.nTableWrapper},1)});y("draw()",function(a){return this.iterator("table",function(b){"page"===a?ja(b):("string"===typeof a&&(a="full-hold"===a?!1:!0),ka(b,!1===a))})});y("page()",function(a){return a===q?this.page.info().page:this.iterator("table",function(b){Ra(b,a)})});y("page.info()",function(a){if(0===this.context.length)return q;
a=this.context[0];var b=a._iDisplayStart,c=a.oFeatures.bPaginate?a._iDisplayLength:-1,d=a.fnRecordsDisplay(),e=-1===c;return{page:e?0:Math.floor(b/c),pages:e?1:Math.ceil(d/c),start:b,end:a.fnDisplayEnd(),length:c,recordsTotal:a.fnRecordsTotal(),recordsDisplay:d,serverSide:"ssp"===Q(a)}});y("page.len()",function(a){return a===q?0!==this.context.length?this.context[0]._iDisplayLength:q:this.iterator("table",function(b){kb(b,a)})});var pc=function(a,b,c){if(c){var d=new B(a);d.one("draw",function(){c(d.ajax.json())})}if("ssp"==
Q(a))ka(a,b);else{V(a,!0);var e=a.jqXHR;e&&4!==e.readyState&&e.abort();Oa(a,[],function(h){Ka(a);h=za(a,h);for(var f=0,g=h.length;f<g;f++)ia(a,h[f]);ka(a,b);V(a,!1)})}};y("ajax.json()",function(){var a=this.context;if(0<a.length)return a[0].json});y("ajax.params()",function(){var a=this.context;if(0<a.length)return a[0].oAjaxData});y("ajax.reload()",function(a,b){return this.iterator("table",function(c){pc(c,!1===b,a)})});y("ajax.url()",function(a){var b=this.context;if(a===q){if(0===b.length)return q;
b=b[0];return b.ajax?l.isPlainObject(b.ajax)?b.ajax.url:b.ajax:b.sAjaxSource}return this.iterator("table",function(c){l.isPlainObject(c.ajax)?c.ajax.url=a:c.ajax=a})});y("ajax.url().load()",function(a,b){return this.iterator("table",function(c){pc(c,!1===b,a)})});var ub=function(a,b,c,d,e){var h=[],f,g,k;var m=typeof b;b&&"string"!==m&&"function"!==m&&b.length!==q||(b=[b]);m=0;for(g=b.length;m<g;m++){var n=b[m]&&b[m].split&&!b[m].match(/[\[\(:]/)?b[m].split(","):[b[m]];var p=0;for(k=n.length;p<k;p++)(f=
c("string"===typeof n[p]?n[p].trim():n[p]))&&f.length&&(h=h.concat(f))}a=M.selector[a];if(a.length)for(m=0,g=a.length;m<g;m++)h=a[m](d,e,h);return Ma(h)},vb=function(a){a||(a={});a.filter&&a.search===q&&(a.search=a.filter);return l.extend({search:"none",order:"current",page:"all"},a)},wb=function(a){for(var b=0,c=a.length;b<c;b++)if(0<a[b].length)return a[0]=a[b],a[0].length=1,a.length=1,a.context=[a.context[b]],a;a.length=0;return a},Wa=function(a,b){var c=[],d=a.aiDisplay;var e=a.aiDisplayMaster;
var h=b.search;var f=b.order;b=b.page;if("ssp"==Q(a))return"removed"===h?[]:qa(0,e.length);if("current"==b)for(f=a._iDisplayStart,a=a.fnDisplayEnd();f<a;f++)c.push(d[f]);else if("current"==f||"applied"==f)if("none"==h)c=e.slice();else if("applied"==h)c=d.slice();else{if("removed"==h){var g={};f=0;for(a=d.length;f<a;f++)g[d[f]]=null;c=l.map(e,function(k){return g.hasOwnProperty(k)?null:k})}}else if("index"==f||"original"==f)for(f=0,a=a.aoData.length;f<a;f++)"none"==h?c.push(f):(e=l.inArray(f,d),(-1===
e&&"removed"==h||0<=e&&"applied"==h)&&c.push(f));return c},yc=function(a,b,c){var d;return ub("row",b,function(e){var h=hc(e),f=a.aoData;if(null!==h&&!c)return[h];d||(d=Wa(a,c));if(null!==h&&-1!==l.inArray(h,d))return[h];if(null===e||e===q||""===e)return d;if("function"===typeof e)return l.map(d,function(k){var m=f[k];return e(k,m._aData,m.nTr)?k:null});if(e.nodeName){h=e._DT_RowIndex;var g=e._DT_CellIndex;if(h!==q)return f[h]&&f[h].nTr===e?[h]:[];if(g)return f[g.row]&&f[g.row].nTr===e.parentNode?
[g.row]:[];h=l(e).closest("*[data-dt-row]");return h.length?[h.data("dt-row")]:[]}if("string"===typeof e&&"#"===e.charAt(0)&&(h=a.aIds[e.replace(/^#/,"")],h!==q))return[h.idx];h=kc(Ea(a.aoData,d,"nTr"));return l(h).filter(e).map(function(){return this._DT_RowIndex}).toArray()},a,c)};y("rows()",function(a,b){a===q?a="":l.isPlainObject(a)&&(b=a,a="");b=vb(b);var c=this.iterator("table",function(d){return yc(d,a,b)},1);c.selector.rows=a;c.selector.opts=b;return c});y("rows().nodes()",function(){return this.iterator("row",
function(a,b){return a.aoData[b].nTr||q},1)});y("rows().data()",function(){return this.iterator(!0,"rows",function(a,b){return Ea(a.aoData,b,"_aData")},1)});J("rows().cache()","row().cache()",function(a){return this.iterator("row",function(b,c){b=b.aoData[c];return"search"===a?b._aFilterData:b._aSortData},1)});J("rows().invalidate()","row().invalidate()",function(a){return this.iterator("row",function(b,c){va(b,c,a)})});J("rows().indexes()","row().index()",function(){return this.iterator("row",function(a,
b){return b},1)});J("rows().ids()","row().id()",function(a){for(var b=[],c=this.context,d=0,e=c.length;d<e;d++)for(var h=0,f=this[d].length;h<f;h++){var g=c[d].rowIdFn(c[d].aoData[this[d][h]]._aData);b.push((!0===a?"#":"")+g)}return new B(c,b)});J("rows().remove()","row().remove()",function(){var a=this;this.iterator("row",function(b,c,d){var e=b.aoData,h=e[c],f,g;e.splice(c,1);var k=0;for(f=e.length;k<f;k++){var m=e[k];var n=m.anCells;null!==m.nTr&&(m.nTr._DT_RowIndex=k);if(null!==n)for(m=0,g=n.length;m<
g;m++)n[m]._DT_CellIndex.row=k}La(b.aiDisplayMaster,c);La(b.aiDisplay,c);La(a[d],c,!1);0<b._iRecordsDisplay&&b._iRecordsDisplay--;lb(b);c=b.rowIdFn(h._aData);c!==q&&delete b.aIds[c]});this.iterator("table",function(b){for(var c=0,d=b.aoData.length;c<d;c++)b.aoData[c].idx=c});return this});y("rows.add()",function(a){var b=this.iterator("table",function(d){var e,h=[];var f=0;for(e=a.length;f<e;f++){var g=a[f];g.nodeName&&"TR"===g.nodeName.toUpperCase()?h.push(Ja(d,g)[0]):h.push(ia(d,g))}return h},1),
c=this.rows(-1);c.pop();l.merge(c,b);return c});y("row()",function(a,b){return wb(this.rows(a,b))});y("row().data()",function(a){var b=this.context;if(a===q)return b.length&&this.length?b[0].aoData[this[0]]._aData:q;var c=b[0].aoData[this[0]];c._aData=a;Array.isArray(a)&&c.nTr&&c.nTr.id&&ha(b[0].rowId)(a,c.nTr.id);va(b[0],this[0],"data");return this});y("row().node()",function(){var a=this.context;return a.length&&this.length?a[0].aoData[this[0]].nTr||null:null});y("row.add()",function(a){a instanceof
l&&a.length&&(a=a[0]);var b=this.iterator("table",function(c){return a.nodeName&&"TR"===a.nodeName.toUpperCase()?Ja(c,a)[0]:ia(c,a)});return this.row(b[0])});l(A).on("plugin-init.dt",function(a,b){a=new B(b);a.on("stateSaveParams",function(d,e,h){d=e.rowIdFn;e=e.aoData;for(var f=[],g=0;g<e.length;g++)e[g]._detailsShow&&f.push("#"+d(e[g]._aData));h.childRows=f});var c=a.state.loaded();c&&c.childRows&&a.rows(l.map(c.childRows,function(d){return d.replace(/:/g,"\\:")})).every(function(){F(b,null,"requestChild",
[this])})});var zc=function(a,b,c,d){var e=[],h=function(f,g){if(Array.isArray(f)||f instanceof l)for(var k=0,m=f.length;k<m;k++)h(f[k],g);else f.nodeName&&"tr"===f.nodeName.toLowerCase()?e.push(f):(k=l("<tr><td></td></tr>").addClass(g),l("td",k).addClass(g).html(f)[0].colSpan=oa(a),e.push(k[0]))};h(c,d);b._details&&b._details.detach();b._details=l(e);b._detailsShow&&b._details.insertAfter(b.nTr)},qc=u.util.throttle(function(a){Ca(a[0])},500),xb=function(a,b){var c=a.context;c.length&&(a=c[0].aoData[b!==
q?b:a[0]])&&a._details&&(a._details.remove(),a._detailsShow=q,a._details=q,l(a.nTr).removeClass("dt-hasChild"),qc(c))},rc=function(a,b){var c=a.context;if(c.length&&a.length){var d=c[0].aoData[a[0]];d._details&&((d._detailsShow=b)?(d._details.insertAfter(d.nTr),l(d.nTr).addClass("dt-hasChild")):(d._details.detach(),l(d.nTr).removeClass("dt-hasChild")),F(c[0],null,"childRow",[b,a.row(a[0])]),Ac(c[0]),qc(c))}},Ac=function(a){var b=new B(a),c=a.aoData;b.off("draw.dt.DT_details column-visibility.dt.DT_details destroy.dt.DT_details");
0<U(c,"_details").length&&(b.on("draw.dt.DT_details",function(d,e){a===e&&b.rows({page:"current"}).eq(0).each(function(h){h=c[h];h._detailsShow&&h._details.insertAfter(h.nTr)})}),b.on("column-visibility.dt.DT_details",function(d,e,h,f){if(a===e)for(e=oa(e),h=0,f=c.length;h<f;h++)d=c[h],d._details&&d._details.children("td[colspan]").attr("colspan",e)}),b.on("destroy.dt.DT_details",function(d,e){if(a===e)for(d=0,e=c.length;d<e;d++)c[d]._details&&xb(b,d)}))};y("row().child()",function(a,b){var c=this.context;
if(a===q)return c.length&&this.length?c[0].aoData[this[0]]._details:q;!0===a?this.child.show():!1===a?xb(this):c.length&&this.length&&zc(c[0],c[0].aoData[this[0]],a,b);return this});y(["row().child.show()","row().child().show()"],function(a){rc(this,!0);return this});y(["row().child.hide()","row().child().hide()"],function(){rc(this,!1);return this});y(["row().child.remove()","row().child().remove()"],function(){xb(this);return this});y("row().child.isShown()",function(){var a=this.context;return a.length&&
this.length?a[0].aoData[this[0]]._detailsShow||!1:!1});var Bc=/^([^:]+):(name|visIdx|visible)$/,sc=function(a,b,c,d,e){c=[];d=0;for(var h=e.length;d<h;d++)c.push(T(a,e[d],b));return c},Cc=function(a,b,c){var d=a.aoColumns,e=U(d,"sName"),h=U(d,"nTh");return ub("column",b,function(f){var g=hc(f);if(""===f)return qa(d.length);if(null!==g)return[0<=g?g:d.length+g];if("function"===typeof f){var k=Wa(a,c);return l.map(d,function(p,t){return f(t,sc(a,t,0,0,k),h[t])?t:null})}var m="string"===typeof f?f.match(Bc):
"";if(m)switch(m[2]){case "visIdx":case "visible":g=parseInt(m[1],10);if(0>g){var n=l.map(d,function(p,t){return p.bVisible?t:null});return[n[n.length+g]]}return[ta(a,g)];case "name":return l.map(e,function(p,t){return p===m[1]?t:null});default:return[]}if(f.nodeName&&f._DT_CellIndex)return[f._DT_CellIndex.column];g=l(h).filter(f).map(function(){return l.inArray(this,h)}).toArray();if(g.length||!f.nodeName)return g;g=l(f).closest("*[data-dt-column]");return g.length?[g.data("dt-column")]:[]},a,c)};
y("columns()",function(a,b){a===q?a="":l.isPlainObject(a)&&(b=a,a="");b=vb(b);var c=this.iterator("table",function(d){return Cc(d,a,b)},1);c.selector.cols=a;c.selector.opts=b;return c});J("columns().header()","column().header()",function(a,b){return this.iterator("column",function(c,d){return c.aoColumns[d].nTh},1)});J("columns().footer()","column().footer()",function(a,b){return this.iterator("column",function(c,d){return c.aoColumns[d].nTf},1)});J("columns().data()","column().data()",function(){return this.iterator("column-rows",
sc,1)});J("columns().dataSrc()","column().dataSrc()",function(){return this.iterator("column",function(a,b){return a.aoColumns[b].mData},1)});J("columns().cache()","column().cache()",function(a){return this.iterator("column-rows",function(b,c,d,e,h){return Ea(b.aoData,h,"search"===a?"_aFilterData":"_aSortData",c)},1)});J("columns().nodes()","column().nodes()",function(){return this.iterator("column-rows",function(a,b,c,d,e){return Ea(a.aoData,e,"anCells",b)},1)});J("columns().visible()","column().visible()",
function(a,b){var c=this,d=this.iterator("column",function(e,h){if(a===q)return e.aoColumns[h].bVisible;var f=e.aoColumns,g=f[h],k=e.aoData,m;if(a!==q&&g.bVisible!==a){if(a){var n=l.inArray(!0,U(f,"bVisible"),h+1);f=0;for(m=k.length;f<m;f++){var p=k[f].nTr;e=k[f].anCells;p&&p.insertBefore(e[h],e[n]||null)}}else l(U(e.aoData,"anCells",h)).detach();g.bVisible=a}});a!==q&&this.iterator("table",function(e){xa(e,e.aoHeader);xa(e,e.aoFooter);e.aiDisplay.length||l(e.nTBody).find("td[colspan]").attr("colspan",
oa(e));Ca(e);c.iterator("column",function(h,f){F(h,null,"column-visibility",[h,f,a,b])});(b===q||b)&&c.columns.adjust()});return d});J("columns().indexes()","column().index()",function(a){return this.iterator("column",function(b,c){return"visible"===a?ua(b,c):c},1)});y("columns.adjust()",function(){return this.iterator("table",function(a){sa(a)},1)});y("column.index()",function(a,b){if(0!==this.context.length){var c=this.context[0];if("fromVisible"===a||"toData"===a)return ta(c,b);if("fromData"===
a||"toVisible"===a)return ua(c,b)}});y("column()",function(a,b){return wb(this.columns(a,b))});var Dc=function(a,b,c){var d=a.aoData,e=Wa(a,c),h=kc(Ea(d,e,"anCells")),f=l(lc([],h)),g,k=a.aoColumns.length,m,n,p,t,v,x;return ub("cell",b,function(w){var r="function"===typeof w;if(null===w||w===q||r){m=[];n=0;for(p=e.length;n<p;n++)for(g=e[n],t=0;t<k;t++)v={row:g,column:t},r?(x=d[g],w(v,T(a,g,t),x.anCells?x.anCells[t]:null)&&m.push(v)):m.push(v);return m}if(l.isPlainObject(w))return w.column!==q&&w.row!==
q&&-1!==l.inArray(w.row,e)?[w]:[];r=f.filter(w).map(function(C,G){return{row:G._DT_CellIndex.row,column:G._DT_CellIndex.column}}).toArray();if(r.length||!w.nodeName)return r;x=l(w).closest("*[data-dt-row]");return x.length?[{row:x.data("dt-row"),column:x.data("dt-column")}]:[]},a,c)};y("cells()",function(a,b,c){l.isPlainObject(a)&&(a.row===q?(c=a,a=null):(c=b,b=null));l.isPlainObject(b)&&(c=b,b=null);if(null===b||b===q)return this.iterator("table",function(n){return Dc(n,a,vb(c))});var d=c?{page:c.page,
order:c.order,search:c.search}:{},e=this.columns(b,d),h=this.rows(a,d),f,g,k,m;d=this.iterator("table",function(n,p){n=[];f=0;for(g=h[p].length;f<g;f++)for(k=0,m=e[p].length;k<m;k++)n.push({row:h[p][f],column:e[p][k]});return n},1);d=c&&c.selected?this.cells(d,c):d;l.extend(d.selector,{cols:b,rows:a,opts:c});return d});J("cells().nodes()","cell().node()",function(){return this.iterator("cell",function(a,b,c){return(a=a.aoData[b])&&a.anCells?a.anCells[c]:q},1)});y("cells().data()",function(){return this.iterator("cell",
function(a,b,c){return T(a,b,c)},1)});J("cells().cache()","cell().cache()",function(a){a="search"===a?"_aFilterData":"_aSortData";return this.iterator("cell",function(b,c,d){return b.aoData[c][a][d]},1)});J("cells().render()","cell().render()",function(a){return this.iterator("cell",function(b,c,d){return T(b,c,d,a)},1)});J("cells().indexes()","cell().index()",function(){return this.iterator("cell",function(a,b,c){return{row:b,column:c,columnVisible:ua(a,c)}},1)});J("cells().invalidate()","cell().invalidate()",
function(a){return this.iterator("cell",function(b,c,d){va(b,c,a,d)})});y("cell()",function(a,b,c){return wb(this.cells(a,b,c))});y("cell().data()",function(a){var b=this.context,c=this[0];if(a===q)return b.length&&c.length?T(b[0],c[0].row,c[0].column):q;Eb(b[0],c[0].row,c[0].column,a);va(b[0],c[0].row,"data",c[0].column);return this});y("order()",function(a,b){var c=this.context;if(a===q)return 0!==c.length?c[0].aaSorting:q;"number"===typeof a?a=[[a,b]]:a.length&&!Array.isArray(a[0])&&(a=Array.prototype.slice.call(arguments));
return this.iterator("table",function(d){d.aaSorting=a.slice()})});y("order.listener()",function(a,b,c){return this.iterator("table",function(d){fb(d,a,b,c)})});y("order.fixed()",function(a){if(!a){var b=this.context;b=b.length?b[0].aaSortingFixed:q;return Array.isArray(b)?{pre:b}:b}return this.iterator("table",function(c){c.aaSortingFixed=l.extend(!0,{},a)})});y(["columns().order()","column().order()"],function(a){var b=this;return this.iterator("table",function(c,d){var e=[];l.each(b[d],function(h,
f){e.push([f,a])});c.aaSorting=e})});y("search()",function(a,b,c,d){var e=this.context;return a===q?0!==e.length?e[0].oPreviousSearch.sSearch:q:this.iterator("table",function(h){h.oFeatures.bFilter&&ya(h,l.extend({},h.oPreviousSearch,{sSearch:a+"",bRegex:null===b?!1:b,bSmart:null===c?!0:c,bCaseInsensitive:null===d?!0:d}),1)})});J("columns().search()","column().search()",function(a,b,c,d){return this.iterator("column",function(e,h){var f=e.aoPreSearchCols;if(a===q)return f[h].sSearch;e.oFeatures.bFilter&&
(l.extend(f[h],{sSearch:a+"",bRegex:null===b?!1:b,bSmart:null===c?!0:c,bCaseInsensitive:null===d?!0:d}),ya(e,e.oPreviousSearch,1))})});y("state()",function(){return this.context.length?this.context[0].oSavedState:null});y("state.clear()",function(){return this.iterator("table",function(a){a.fnStateSaveCallback.call(a.oInstance,a,{})})});y("state.loaded()",function(){return this.context.length?this.context[0].oLoadedState:null});y("state.save()",function(){return this.iterator("table",function(a){Ca(a)})});
u.versionCheck=u.fnVersionCheck=function(a){var b=u.version.split(".");a=a.split(".");for(var c,d,e=0,h=a.length;e<h;e++)if(c=parseInt(b[e],10)||0,d=parseInt(a[e],10)||0,c!==d)return c>d;return!0};u.isDataTable=u.fnIsDataTable=function(a){var b=l(a).get(0),c=!1;if(a instanceof u.Api)return!0;l.each(u.settings,function(d,e){d=e.nScrollHead?l("table",e.nScrollHead)[0]:null;var h=e.nScrollFoot?l("table",e.nScrollFoot)[0]:null;if(e.nTable===b||d===b||h===b)c=!0});return c};u.tables=u.fnTables=function(a){var b=
!1;l.isPlainObject(a)&&(b=a.api,a=a.visible);var c=l.map(u.settings,function(d){if(!a||a&&l(d.nTable).is(":visible"))return d.nTable});return b?new B(c):c};u.camelToHungarian=P;y("$()",function(a,b){b=this.rows(b).nodes();b=l(b);return l([].concat(b.filter(a).toArray(),b.find(a).toArray()))});l.each(["on","one","off"],function(a,b){y(b+"()",function(){var c=Array.prototype.slice.call(arguments);c[0]=l.map(c[0].split(/\s/),function(e){return e.match(/\.dt\b/)?e:e+".dt"}).join(" ");var d=l(this.tables().nodes());
d[b].apply(d,c);return this})});y("clear()",function(){return this.iterator("table",function(a){Ka(a)})});y("settings()",function(){return new B(this.context,this.context)});y("init()",function(){var a=this.context;return a.length?a[0].oInit:null});y("data()",function(){return this.iterator("table",function(a){return U(a.aoData,"_aData")}).flatten()});y("destroy()",function(a){a=a||!1;return this.iterator("table",function(b){var c=b.nTableWrapper.parentNode,d=b.oClasses,e=b.nTable,h=b.nTBody,f=b.nTHead,
g=b.nTFoot,k=l(e);h=l(h);var m=l(b.nTableWrapper),n=l.map(b.aoData,function(t){return t.nTr}),p;b.bDestroying=!0;F(b,"aoDestroyCallback","destroy",[b]);a||(new B(b)).columns().visible(!0);m.off(".DT").find(":not(tbody *)").off(".DT");l(z).off(".DT-"+b.sInstance);e!=f.parentNode&&(k.children("thead").detach(),k.append(f));g&&e!=g.parentNode&&(k.children("tfoot").detach(),k.append(g));b.aaSorting=[];b.aaSortingFixed=[];Sa(b);l(n).removeClass(b.asStripeClasses.join(" "));l("th, td",f).removeClass(d.sSortable+
" "+d.sSortableAsc+" "+d.sSortableDesc+" "+d.sSortableNone);h.children().detach();h.append(n);f=a?"remove":"detach";k[f]();m[f]();!a&&c&&(c.insertBefore(e,b.nTableReinsertBefore),k.css("width",b.sDestroyWidth).removeClass(d.sTable),(p=b.asDestroyStripes.length)&&h.children().each(function(t){l(this).addClass(b.asDestroyStripes[t%p])}));c=l.inArray(b,u.settings);-1!==c&&u.settings.splice(c,1)})});l.each(["column","row","cell"],function(a,b){y(b+"s().every()",function(c){var d=this.selector.opts,e=
this;return this.iterator(b,function(h,f,g,k,m){c.call(e[b](f,"cell"===b?g:d,"cell"===b?d:q),f,g,k,m)})})});y("i18n()",function(a,b,c){var d=this.context[0];a=na(a)(d.oLanguage);a===q&&(a=b);c!==q&&l.isPlainObject(a)&&(a=a[c]!==q?a[c]:a._);return a.replace("%d",c)});u.version="1.11.5";u.settings=[];u.models={};u.models.oSearch={bCaseInsensitive:!0,sSearch:"",bRegex:!1,bSmart:!0,"return":!1};u.models.oRow={nTr:null,anCells:null,_aData:[],_aSortData:null,_aFilterData:null,_sFilterRow:null,_sRowStripe:"",
src:null,idx:-1};u.models.oColumn={idx:null,aDataSort:null,asSorting:null,bSearchable:null,bSortable:null,bVisible:null,_sManualType:null,_bAttrSrc:!1,fnCreatedCell:null,fnGetData:null,fnSetData:null,mData:null,mRender:null,nTh:null,nTf:null,sClass:null,sContentPadding:null,sDefaultContent:null,sName:null,sSortDataType:"std",sSortingClass:null,sSortingClassJUI:null,sTitle:null,sType:null,sWidth:null,sWidthOrig:null};u.defaults={aaData:null,aaSorting:[[0,"asc"]],aaSortingFixed:[],ajax:null,aLengthMenu:[10,
25,50,100],aoColumns:null,aoColumnDefs:null,aoSearchCols:[],asStripeClasses:null,bAutoWidth:!0,bDeferRender:!1,bDestroy:!1,bFilter:!0,bInfo:!0,bLengthChange:!0,bPaginate:!0,bProcessing:!1,bRetrieve:!1,bScrollCollapse:!1,bServerSide:!1,bSort:!0,bSortMulti:!0,bSortCellsTop:!1,bSortClasses:!0,bStateSave:!1,fnCreatedRow:null,fnDrawCallback:null,fnFooterCallback:null,fnFormatNumber:function(a){return a.toString().replace(/\B(?=(\d{3})+(?!\d))/g,this.oLanguage.sThousands)},fnHeaderCallback:null,fnInfoCallback:null,
fnInitComplete:null,fnPreDrawCallback:null,fnRowCallback:null,fnServerData:null,fnServerParams:null,fnStateLoadCallback:function(a){try{return JSON.parse((-1===a.iStateDuration?sessionStorage:localStorage).getItem("DataTables_"+a.sInstance+"_"+location.pathname))}catch(b){return{}}},fnStateLoadParams:null,fnStateLoaded:null,fnStateSaveCallback:function(a,b){try{(-1===a.iStateDuration?sessionStorage:localStorage).setItem("DataTables_"+a.sInstance+"_"+location.pathname,JSON.stringify(b))}catch(c){}},
fnStateSaveParams:null,iStateDuration:7200,iDeferLoading:null,iDisplayLength:10,iDisplayStart:0,iTabIndex:0,oClasses:{},oLanguage:{oAria:{sSortAscending:": activate to sort column ascending",sSortDescending:": activate to sort column descending"},oPaginate:{sFirst:"First",sLast:"Last",sNext:"Next",sPrevious:"Previous"},sEmptyTable:"No data available in table",sInfo:"Showing _START_ to _END_ of _TOTAL_ entries",sInfoEmpty:"Showing 0 to 0 of 0 entries",sInfoFiltered:"(filtered from _MAX_ total entries)",
sInfoPostFix:"",sDecimal:"",sThousands:",",sLengthMenu:"Show _MENU_ entries",sLoadingRecords:"Loading...",sProcessing:"Processing...",sSearch:"Search:",sSearchPlaceholder:"",sUrl:"",sZeroRecords:"No matching records found"},oSearch:l.extend({},u.models.oSearch),sAjaxDataProp:"data",sAjaxSource:null,sDom:"lfrtip",searchDelay:null,sPaginationType:"simple_numbers",sScrollX:"",sScrollXInner:"",sScrollY:"",sServerMethod:"GET",renderer:null,rowId:"DT_RowId"};E(u.defaults);u.defaults.column={aDataSort:null,
iDataSort:-1,asSorting:["asc","desc"],bSearchable:!0,bSortable:!0,bVisible:!0,fnCreatedCell:null,mData:null,mRender:null,sCellType:"td",sClass:"",sContentPadding:"",sDefaultContent:null,sName:"",sSortDataType:"std",sTitle:null,sType:null,sWidth:null};E(u.defaults.column);u.models.oSettings={oFeatures:{bAutoWidth:null,bDeferRender:null,bFilter:null,bInfo:null,bLengthChange:null,bPaginate:null,bProcessing:null,bServerSide:null,bSort:null,bSortMulti:null,bSortClasses:null,bStateSave:null},oScroll:{bCollapse:null,
iBarWidth:0,sX:null,sXInner:null,sY:null},oLanguage:{fnInfoCallback:null},oBrowser:{bScrollOversize:!1,bScrollbarLeft:!1,bBounding:!1,barWidth:0},ajax:null,aanFeatures:[],aoData:[],aiDisplay:[],aiDisplayMaster:[],aIds:{},aoColumns:[],aoHeader:[],aoFooter:[],oPreviousSearch:{},aoPreSearchCols:[],aaSorting:null,aaSortingFixed:[],asStripeClasses:null,asDestroyStripes:[],sDestroyWidth:0,aoRowCallback:[],aoHeaderCallback:[],aoFooterCallback:[],aoDrawCallback:[],aoRowCreatedCallback:[],aoPreDrawCallback:[],
aoInitComplete:[],aoStateSaveParams:[],aoStateLoadParams:[],aoStateLoaded:[],sTableId:"",nTable:null,nTHead:null,nTFoot:null,nTBody:null,nTableWrapper:null,bDeferLoading:!1,bInitialised:!1,aoOpenRows:[],sDom:null,searchDelay:null,sPaginationType:"two_button",iStateDuration:0,aoStateSave:[],aoStateLoad:[],oSavedState:null,oLoadedState:null,sAjaxSource:null,sAjaxDataProp:null,jqXHR:null,json:q,oAjaxData:q,fnServerData:null,aoServerParams:[],sServerMethod:null,fnFormatNumber:null,aLengthMenu:null,iDraw:0,
bDrawing:!1,iDrawError:-1,_iDisplayLength:10,_iDisplayStart:0,_iRecordsTotal:0,_iRecordsDisplay:0,oClasses:{},bFiltered:!1,bSorted:!1,bSortCellsTop:null,oInit:null,aoDestroyCallback:[],fnRecordsTotal:function(){return"ssp"==Q(this)?1*this._iRecordsTotal:this.aiDisplayMaster.length},fnRecordsDisplay:function(){return"ssp"==Q(this)?1*this._iRecordsDisplay:this.aiDisplay.length},fnDisplayEnd:function(){var a=this._iDisplayLength,b=this._iDisplayStart,c=b+a,d=this.aiDisplay.length,e=this.oFeatures,h=
e.bPaginate;return e.bServerSide?!1===h||-1===a?b+d:Math.min(b+a,this._iRecordsDisplay):!h||c>d||-1===a?d:c},oInstance:null,sInstance:null,iTabIndex:0,nScrollHead:null,nScrollFoot:null,aLastSort:[],oPlugins:{},rowIdFn:null,rowId:null};u.ext=M={buttons:{},classes:{},builder:"-source-",errMode:"alert",feature:[],search:[],selector:{cell:[],column:[],row:[]},internal:{},legacy:{ajax:null},pager:{},renderer:{pageButton:{},header:{}},order:{},type:{detect:[],search:{},order:{}},_unique:0,fnVersionCheck:u.fnVersionCheck,
iApiIndex:0,oJUIClasses:{},sVersion:u.version};l.extend(M,{afnFiltering:M.search,aTypes:M.type.detect,ofnSearch:M.type.search,oSort:M.type.order,afnSortData:M.order,aoFeatures:M.feature,oApi:M.internal,oStdClasses:M.classes,oPagination:M.pager});l.extend(u.ext.classes,{sTable:"dataTable",sNoFooter:"no-footer",sPageButton:"paginate_button",sPageButtonActive:"current",sPageButtonDisabled:"disabled",sStripeOdd:"odd",sStripeEven:"even",sRowEmpty:"dataTables_empty",sWrapper:"dataTables_wrapper",sFilter:"dataTables_filter",
sInfo:"dataTables_info",sPaging:"dataTables_paginate paging_",sLength:"dataTables_length",sProcessing:"dataTables_processing",sSortAsc:"sorting_asc",sSortDesc:"sorting_desc",sSortable:"sorting",sSortableAsc:"sorting_desc_disabled",sSortableDesc:"sorting_asc_disabled",sSortableNone:"sorting_disabled",sSortColumn:"sorting_",sFilterInput:"",sLengthSelect:"",sScrollWrapper:"dataTables_scroll",sScrollHead:"dataTables_scrollHead",sScrollHeadInner:"dataTables_scrollHeadInner",sScrollBody:"dataTables_scrollBody",
sScrollFoot:"dataTables_scrollFoot",sScrollFootInner:"dataTables_scrollFootInner",sHeaderTH:"",sFooterTH:"",sSortJUIAsc:"",sSortJUIDesc:"",sSortJUI:"",sSortJUIAscAllowed:"",sSortJUIDescAllowed:"",sSortJUIWrapper:"",sSortIcon:"",sJUIHeader:"",sJUIFooter:""});var ec=u.ext.pager;l.extend(ec,{simple:function(a,b){return["previous","next"]},full:function(a,b){return["first","previous","next","last"]},numbers:function(a,b){return[Da(a,b)]},simple_numbers:function(a,b){return["previous",Da(a,b),"next"]},
full_numbers:function(a,b){return["first","previous",Da(a,b),"next","last"]},first_last_numbers:function(a,b){return["first",Da(a,b),"last"]},_numbers:Da,numbers_length:7});l.extend(!0,u.ext.renderer,{pageButton:{_:function(a,b,c,d,e,h){var f=a.oClasses,g=a.oLanguage.oPaginate,k=a.oLanguage.oAria.paginate||{},m,n,p=0,t=function(x,w){var r,C=f.sPageButtonDisabled,G=function(I){Ra(a,I.data.action,!0)};var aa=0;for(r=w.length;aa<r;aa++){var L=w[aa];if(Array.isArray(L)){var O=l("<"+(L.DT_el||"div")+"/>").appendTo(x);
t(O,L)}else{m=null;n=L;O=a.iTabIndex;switch(L){case "ellipsis":x.append('<span class="ellipsis">&#x2026;</span>');break;case "first":m=g.sFirst;0===e&&(O=-1,n+=" "+C);break;case "previous":m=g.sPrevious;0===e&&(O=-1,n+=" "+C);break;case "next":m=g.sNext;if(0===h||e===h-1)O=-1,n+=" "+C;break;case "last":m=g.sLast;if(0===h||e===h-1)O=-1,n+=" "+C;break;default:m=a.fnFormatNumber(L+1),n=e===L?f.sPageButtonActive:""}null!==m&&(O=l("<a>",{"class":f.sPageButton+" "+n,"aria-controls":a.sTableId,"aria-label":k[L],
"data-dt-idx":p,tabindex:O,id:0===c&&"string"===typeof L?a.sTableId+"_"+L:null}).html(m).appendTo(x),ob(O,{action:L},G),p++)}}};try{var v=l(b).find(A.activeElement).data("dt-idx")}catch(x){}t(l(b).empty(),d);v!==q&&l(b).find("[data-dt-idx="+v+"]").trigger("focus")}}});l.extend(u.ext.type.detect,[function(a,b){b=b.oLanguage.sDecimal;return tb(a,b)?"num"+b:null},function(a,b){if(a&&!(a instanceof Date)&&!vc.test(a))return null;b=Date.parse(a);return null!==b&&!isNaN(b)||Z(a)?"date":null},function(a,
b){b=b.oLanguage.sDecimal;return tb(a,b,!0)?"num-fmt"+b:null},function(a,b){b=b.oLanguage.sDecimal;return jc(a,b)?"html-num"+b:null},function(a,b){b=b.oLanguage.sDecimal;return jc(a,b,!0)?"html-num-fmt"+b:null},function(a,b){return Z(a)||"string"===typeof a&&-1!==a.indexOf("<")?"html":null}]);l.extend(u.ext.type.search,{html:function(a){return Z(a)?a:"string"===typeof a?a.replace(gc," ").replace(Va,""):""},string:function(a){return Z(a)?a:"string"===typeof a?a.replace(gc," "):a}});var Ua=function(a,
b,c,d){if(0!==a&&(!a||"-"===a))return-Infinity;b&&(a=ic(a,b));a.replace&&(c&&(a=a.replace(c,"")),d&&(a=a.replace(d,"")));return 1*a};l.extend(M.type.order,{"date-pre":function(a){a=Date.parse(a);return isNaN(a)?-Infinity:a},"html-pre":function(a){return Z(a)?"":a.replace?a.replace(/<.*?>/g,"").toLowerCase():a+""},"string-pre":function(a){return Z(a)?"":"string"===typeof a?a.toLowerCase():a.toString?a.toString():""},"string-asc":function(a,b){return a<b?-1:a>b?1:0},"string-desc":function(a,b){return a<
b?1:a>b?-1:0}});Xa("");l.extend(!0,u.ext.renderer,{header:{_:function(a,b,c,d){l(a.nTable).on("order.dt.DT",function(e,h,f,g){a===h&&(e=c.idx,b.removeClass(d.sSortAsc+" "+d.sSortDesc).addClass("asc"==g[e]?d.sSortAsc:"desc"==g[e]?d.sSortDesc:c.sSortingClass))})},jqueryui:function(a,b,c,d){l("<div/>").addClass(d.sSortJUIWrapper).append(b.contents()).append(l("<span/>").addClass(d.sSortIcon+" "+c.sSortingClassJUI)).appendTo(b);l(a.nTable).on("order.dt.DT",function(e,h,f,g){a===h&&(e=c.idx,b.removeClass(d.sSortAsc+
" "+d.sSortDesc).addClass("asc"==g[e]?d.sSortAsc:"desc"==g[e]?d.sSortDesc:c.sSortingClass),b.find("span."+d.sSortIcon).removeClass(d.sSortJUIAsc+" "+d.sSortJUIDesc+" "+d.sSortJUI+" "+d.sSortJUIAscAllowed+" "+d.sSortJUIDescAllowed).addClass("asc"==g[e]?d.sSortJUIAsc:"desc"==g[e]?d.sSortJUIDesc:c.sSortingClassJUI))})}}});var yb=function(a){Array.isArray(a)&&(a=a.join(","));return"string"===typeof a?a.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):a};u.render=
{number:function(a,b,c,d,e){return{display:function(h){if("number"!==typeof h&&"string"!==typeof h)return h;var f=0>h?"-":"",g=parseFloat(h);if(isNaN(g))return yb(h);g=g.toFixed(c);h=Math.abs(g);g=parseInt(h,10);h=c?b+(h-g).toFixed(c).substring(2):"";0===g&&0===parseFloat(h)&&(f="");return f+(d||"")+g.toString().replace(/\B(?=(\d{3})+(?!\d))/g,a)+h+(e||"")}}},text:function(){return{display:yb,filter:yb}}};l.extend(u.ext.internal,{_fnExternApiFunc:fc,_fnBuildAjax:Oa,_fnAjaxUpdate:Gb,_fnAjaxParameters:Pb,
_fnAjaxUpdateDraw:Qb,_fnAjaxDataSrc:za,_fnAddColumn:Ya,_fnColumnOptions:Ga,_fnAdjustColumnSizing:sa,_fnVisibleToColumnIndex:ta,_fnColumnIndexToVisible:ua,_fnVisbleColumns:oa,_fnGetColumns:Ia,_fnColumnTypes:$a,_fnApplyColumnDefs:Db,_fnHungarianMap:E,_fnCamelToHungarian:P,_fnLanguageCompat:ma,_fnBrowserDetect:Bb,_fnAddData:ia,_fnAddTr:Ja,_fnNodeToDataIndex:function(a,b){return b._DT_RowIndex!==q?b._DT_RowIndex:null},_fnNodeToColumnIndex:function(a,b,c){return l.inArray(c,a.aoData[b].anCells)},_fnGetCellData:T,
_fnSetCellData:Eb,_fnSplitObjNotation:cb,_fnGetObjectDataFn:na,_fnSetObjectDataFn:ha,_fnGetDataMaster:db,_fnClearTable:Ka,_fnDeleteIndex:La,_fnInvalidate:va,_fnGetRowElements:bb,_fnCreateTr:ab,_fnBuildHead:Fb,_fnDrawHead:xa,_fnDraw:ja,_fnReDraw:ka,_fnAddOptionsHtml:Ib,_fnDetectHeader:wa,_fnGetUniqueThs:Na,_fnFeatureHtmlFilter:Kb,_fnFilterComplete:ya,_fnFilterCustom:Tb,_fnFilterColumn:Sb,_fnFilter:Rb,_fnFilterCreateSearch:ib,_fnEscapeRegex:jb,_fnFilterData:Ub,_fnFeatureHtmlInfo:Nb,_fnUpdateInfo:Xb,
_fnInfoMacros:Yb,_fnInitialise:Aa,_fnInitComplete:Pa,_fnLengthChange:kb,_fnFeatureHtmlLength:Jb,_fnFeatureHtmlPaginate:Ob,_fnPageChange:Ra,_fnFeatureHtmlProcessing:Lb,_fnProcessingDisplay:V,_fnFeatureHtmlTable:Mb,_fnScrollDraw:Ha,_fnApplyToChildren:ca,_fnCalculateColumnWidths:Za,_fnThrottle:hb,_fnConvertToWidth:Zb,_fnGetWidestNode:$b,_fnGetMaxLenString:ac,_fnStringToCss:K,_fnSortFlatten:pa,_fnSort:Hb,_fnSortAria:cc,_fnSortListener:nb,_fnSortAttachListener:fb,_fnSortingClasses:Sa,_fnSortData:bc,_fnSaveState:Ca,
_fnLoadState:dc,_fnImplementState:pb,_fnSettingsFromNode:Ta,_fnLog:da,_fnMap:X,_fnBindAction:ob,_fnCallbackReg:R,_fnCallbackFire:F,_fnLengthOverflow:lb,_fnRenderer:gb,_fnDataSource:Q,_fnRowAttributes:eb,_fnExtend:qb,_fnCalculateEnd:function(){}});l.fn.dataTable=u;u.$=l;l.fn.dataTableSettings=u.settings;l.fn.dataTableExt=u.ext;l.fn.DataTable=function(a){return l(this).dataTable(a).api()};l.each(u,function(a,b){l.fn.DataTable[a]=b});return u});

13
static/js/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
static/js/moment.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
static/js/pdfmake.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

6
static/js/vfs_fonts.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ company.name }} - Add User</h2>
<div class="card">
<div class="card-body">
{% if users %}
<form method="POST" action="{{ url_for('auth.add_company_user', company_id=company.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="user_id" class="form-label">Select User</label>
<select class="form-select" id="user_id" name="user_id" required>
<option value="" selected disabled>Choose a user</option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.username }} ({{ user.email }})</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select" id="role" name="role" required>
<option value="User">User</option>
{% if current_user.is_global_admin() %}
<option value="CompanyAdmin">Company Admin</option>
{% endif %}
</select>
</div>
<div class="mb-3">
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Add User</button>
</div>
</form>
{% else %}
<div class="alert alert-info">
<p>All users are already added to this company.</p>
<a href="{{ url_for('auth.manage_users') }}" class="btn btn-primary mt-2">Create New User</a>
</div>
<div class="mt-3">
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-secondary">Back to Company Users</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Admin Settings</h2>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Website Settings</h5>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="allow_registration"
name="allow_registration" {% if settings.allow_registration %}checked{% endif %}>
<label class="form-check-label" for="allow_registration">
Allow User Registration
</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="restrict_email_domains"
name="restrict_email_domains" {% if settings.restrict_email_domains %}checked{% endif %}>
<label class="form-check-label" for="restrict_email_domains">
Restrict Registration to Specific Email Domains
</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="require_mfa_for_all_users"
name="require_mfa_for_all_users" {% if settings.require_mfa_for_all_users %}checked{% endif %}>
<label class="form-check-label" for="require_mfa_for_all_users">
Require MFA for All Users (GlobalAdmin accounts exempt)
</label>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">Password Strength Requirements</h5>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="password_min_length" class="form-label">Minimum Password Length</label>
<input type="number" class="form-control" id="password_min_length"
name="password_min_length" min="6" max="128"
value="{{ settings.password_min_length or 10 }}" required>
<div class="form-text">Minimum 6 characters required</div>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="password_require_numbers_mixed_case"
name="password_require_numbers_mixed_case"
{% if settings.password_require_numbers_mixed_case %}checked{% endif %}>
<label class="form-check-label" for="password_require_numbers_mixed_case">
Require Numbers and Mixed Case Letters
</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="password_require_special_chars"
name="password_require_special_chars"
{% if settings.password_require_special_chars %}checked{% endif %}>
<label class="form-check-label" for="password_require_special_chars">
Require Special Characters
</label>
</div>
<div class="mb-3">
<label for="password_safe_special_chars" class="form-label">Safe Special Characters</label>
<input type="text" class="form-control" id="password_safe_special_chars"
name="password_safe_special_chars"
value="{{ settings.password_safe_special_chars or '!@#$%^&*()_+-=[]{}|;:,.<>?' }}">
<div class="form-text">Characters allowed for special character requirement</div>
</div>
<button type="submit" class="btn btn-primary">Save Password Settings</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">Database Logging Configuration</h5>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="log_level" class="form-label">Database Logging Level</label>
<select class="form-select" id="log_level" name="log_level">
{% for level_value, level_description in available_log_levels %}
<option value="{{ level_value }}"
{% if settings.log_level == level_value %}selected{% endif %}>
{{ level_description }}
</option>
{% endfor %}
</select>
<div class="form-text">
Controls which log messages are saved to the database. Lower levels include all higher levels.
<br><strong>Note:</strong> DEBUG and INFO levels may generate many log entries and increase database size.
</div>
</div>
<button type="submit" class="btn btn-primary">Save Logging Settings</button>
</form>
<div class="mt-3">
<h6>Current Log Level: <span class="badge bg-primary">{{ settings.log_level or 'WARNING' }}</span></h6>
<div class="small text-muted">
<p><strong>Level Descriptions:</strong></p>
<ul class="mb-0">
<li><strong>DEBUG:</strong> All messages including detailed debugging information</li>
<li><strong>INFO:</strong> General information (logins, registrations, etc.)</li>
<li><strong>WARNING:</strong> Warnings and potential issues (failed logins, etc.)</li>
<li><strong>ERROR:</strong> Error messages and exceptions</li>
<li><strong>CRITICAL:</strong> Only critical system errors</li>
</ul>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">Allowed Email Domains</h5>
<form method="POST" action="{{ url_for('auth.add_allowed_domain') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group mb-3">
<input type="text" class="form-control" name="domain"
placeholder="example.com" required>
<button class="btn btn-outline-primary" type="submit">Add Domain</button>
</div>
</form>
<div class="mt-3">
{% if allowed_domains %}
<ul class="list-group">
{% for domain in allowed_domains %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ domain.domain }}
<form method="POST" action="{{ url_for('auth.delete_allowed_domain', domain_id=domain.id) }}"
style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to remove this domain?')">
Remove
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">No domains added yet. When domains are restricted, only users with these email domains can register.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>{{ company.name }} - Sites (API Keys)</h2>
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Create New API Key</h5>
<a href="{{ url_for('auth.download_agent', company_id=company.id) }}" class="btn btn-success">
<i class="fas fa-download"></i> Download Windows Agent
</a>
</div>
<form method="POST" action="{{ url_for('auth.create_company_api_key', company_id=company.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<input type="text" class="form-control" id="description" name="description" required>
</div>
<button type="submit" class="btn btn-primary">Generate New Key</button>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Company Sites (API Keys)</h5>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> These API keys can be used to authenticate windows agents. You can
<a href="{{ url_for('auth.download_agent', company_id=company.id) }}">download a pre-configured agent</a>
with your selected API key.
</div>
<div class="table-responsive">
<table class="table table-striped" id="apiKeysTable">
<thead>
<tr>
<th>Description</th>
<th>Key</th>
<th>Created</th>
<th>Last Used</th>
<th>Created By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for api_key in api_keys %}
<tr>
<td>{{ api_key.description }}</td>
<td>
<div class="input-group">
<input type="text" class="form-control" value="{{ api_key.key }}" readonly>
<button class="btn btn-outline-secondary copy-btn" type="button" data-key="{{ api_key.key }}">
Copy
</button>
</div>
</td>
<td>{{ api_key.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ api_key.last_used.strftime('%Y-%m-%d %H:%M:%S') if api_key.last_used else 'Never' }}</td>
<td>{{ api_key.user.username }}</td>
<td>
<form action="{{ url_for('auth.delete_company_api_key', company_id=company.id, key_id=api_key.id) }}" method="POST"
style="display:inline" onsubmit="return confirm('Are you sure you want to delete this API key?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3">
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary">Back to Companies</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<script>
$(document).ready(function() {
$('#apiKeysTable').DataTable({
"pageLength": 10,
"order": [[2, "desc"]]
});
document.querySelectorAll('.copy-btn').forEach(button => {
button.addEventListener('click', function() {
const key = this.dataset.key;
navigator.clipboard.writeText(key).then(() => {
this.textContent = 'Copied!';
setTimeout(() => this.textContent = 'Copy', 2000);
});
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>{{ company.name }} - User Management</h2>
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title">Company Users</h5>
<div>
<a href="{{ url_for('auth.download_agent', company_id=company.id) }}" class="btn btn-success me-2">
<i class="fas fa-download"></i> Download Agent
</a>
<a href="{{ url_for('auth.create_company_user', company_id=company.id) }}" class="btn btn-success me-2">Create New User</a>
<a href="{{ url_for('auth.add_company_user', company_id=company.id) }}" class="btn btn-primary">Add Existing User</a>
</div>
</div>
<div class="table-responsive mt-3">
<table class="table table-striped" id="companyUsersTable">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for uc in user_companies %}
<tr>
<td>{{ uc.user.username }}</td>
<td>{{ uc.user.email }}</td>
<td>
{% if current_user.is_global_admin() or current_user.is_company_admin(company.id) %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="roleDropdown{{ uc.id }}" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
{{ uc.role }}
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="roleDropdown{{ uc.id }}">
<li>
<form action="{{ url_for('auth.change_company_user_role', company_id=company.id, user_id=uc.user_id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="role" value="User">
<button type="submit" class="dropdown-item {% if uc.role == 'User' %}active{% endif %}">User</button>
</form>
</li>
<li>
<form action="{{ url_for('auth.change_company_user_role', company_id=company.id, user_id=uc.user_id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="role" value="CompanyAdmin">
<button type="submit" class="dropdown-item {% if uc.role == 'CompanyAdmin' %}active{% endif %}">Company Admin</button>
</form>
</li>
</ul>
</div>
{% else %}
{{ uc.role }}
{% endif %}
</td>
<td>
<form action="{{ url_for('auth.remove_company_user', company_id=company.id, user_id=uc.user_id) }}" method="POST" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-danger"
{% if uc.role == 'CompanyAdmin' and not current_user.is_global_admin() %}disabled{% endif %}
onclick="return confirm('Are you sure you want to remove this user from the company?')">
Remove
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3">
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary">Back to Companies</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<script>
$(document).ready(function() {
// Initialize DataTable
$('#companyUsersTable').DataTable({
"pageLength": 10,
"order": [[0, "asc"]]
});
// Fix dropdown position for items near the bottom of the screen
$('.dropdown-toggle').on('click', function() {
var $button = $(this);
var $dropdownMenu = $button.next('.dropdown-menu');
// Get positions
var buttonOffset = $button.offset();
var buttonHeight = $button.outerHeight();
var dropdownHeight = $dropdownMenu.outerHeight();
// Calculate if dropdown would go off screen
var bottomSpace = $(window).height() - (buttonOffset.top - $(window).scrollTop() + buttonHeight);
if (bottomSpace < dropdownHeight) {
// Not enough space below, make it open upwards
$(this).parent().addClass('dropup');
} else {
$(this).parent().removeClass('dropup');
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Create New Company</h2>
<div class="card">
<div class="card-body">
<form method="POST" action="{{ url_for('auth.create_company') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="name" class="form-label">Company Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
</div>
<div class="mb-3">
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Create Company</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ company.name }} - Create New User</h2>
<div class="card">
<div class="card-body">
<form method="POST" action="{{ url_for('auth.create_company_user', company_id=company.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Create User</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Download Agent - {{ company.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">Download Agent for {{ company.name }}</h5>
</div>
<div class="card-body">
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
Configure and download the Windows monitoring agent with your company's API key pre-configured.
</div>
<div class="card mb-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0">Agent Configuration</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('auth.download_agent', company_id=company.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="api_key" class="form-label">Select API Key</label>
<select class="form-select" id="api_key" name="api_key" required>
<option value="">Select API Key</option>
{% for api_key in api_keys %}
<option value="{{ api_key.id }}">
{{ api_key.key[:8] }}...{{ api_key.key[-8:] }}
{% if api_key.description %}({{ api_key.description }}){% endif %}
</option>
{% endfor %}
</select>
{% if not api_keys %}
<div class="form-text text-warning">
No API keys found. <a href="{{ url_for('auth.company_api_keys', company_id=company.id) }}">Create an API key</a> first.
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="server_url" class="form-label">Server URL</label>
<input type="url" class="form-control" id="server_url" name="server_url" value="{{ default_url }}" required>
<div class="form-text">
The URL where the agent will send login events. Usually the same URL as this website.
</div>
</div>
<div class="mb-3">
<label for="install_dir" class="form-label">Installation Directory (Optional)</label>
<input type="text" class="form-control" id="install_dir" name="install_dir" placeholder="C:\ProgramData\UserSessionMon">
<div class="form-text">
Custom installation directory for the agent. Leave empty to use the default path.
</div>
</div>
<button type="submit" class="btn btn-primary" {% if not api_keys %}disabled{% endif %}>
<i class="fas fa-download"></i> Download Agent
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0">Installation Instructions</h5>
</div>
<div class="card-body">
<ol>
<li>Download the agent package using the form above.</li>
<li>Extract the ZIP file to a folder on your Windows computer.</li>
<li>Run the agent as administrator to install it:
<code>winagentUSM.exe --service install</code>
</li>
<li>The service will start automatically and begin monitoring login events.</li>
<li>Events will be sent to this server using the specified API key.</li>
</ol>
<p><strong>Note:</strong> The agent requires administrator privileges to install and run as a Windows service.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Edit Company</h2>
<div class="card">
<div class="card-body">
<form method="POST" action="{{ url_for('auth.edit_company', company_id=company.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="name" class="form-label">Company Name</label>
<input type="text" class="form-control" id="name" name="name" value="{{ company.name }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ company.description }}</textarea>
</div>
<div class="mb-3">
<a href="{{ url_for('auth.manage_companies') }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Update Company</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,405 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.dataTables.min.css') }}" rel="stylesheet">
<style>
.log-level-CRITICAL { color: #dc3545; font-weight: bold; }
.log-level-ERROR { color: #dc3545; }
.log-level-WARNING { color: #ffc107; }
.log-level-INFO { color: #0dcaf0; }
.log-level-DEBUG { color: #6c757d; }
.log-message {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exception-toggle {
cursor: pointer;
color: #0d6efd;
text-decoration: underline;
}
.exception-details {
background-color: #343a40;
border: 1px solid #495057;
border-radius: 0.375rem;
padding: 1rem;
margin-top: 0.5rem;
font-family: monospace;
font-size: 0.875rem;
white-space: pre-wrap;
overflow-x: auto;
}
.filter-form {
background-color: #343a40;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.stats-cards {
margin-bottom: 1.5rem;
}
.stat-card {
background-color: #343a40;
border: 1px solid #495057;
border-radius: 0.375rem;
padding: 1rem;
text-align: center;
cursor: pointer;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-label {
color: #adb5bd;
font-size: 0.875rem;
}
</style>
{% endblock %}
{% block title %}Error Logs{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Application Error Logs</h2>
<div>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#clearLogsModal">
Clear Old Logs
</button>
<button id="refresh-logs" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-refresh"></i> Refresh
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="row stats-cards">
<div class="col-md">
<div class="stat-card">
<div class="stat-number text-info" id="debug-count">{{ error_logs | selectattr('level', 'equalto', 'DEBUG') | list | length }}</div>
<div class="stat-label">Debug</div>
</div>
</div>
<div class="col-md">
<div class="stat-card">
<div class="stat-number text-info" id="info-count">{{ error_logs | selectattr('level', 'equalto', 'INFO') | list | length }}</div>
<div class="stat-label">Info</div>
</div>
</div>
<div class="col-md">
<div class="stat-card">
<div class="stat-number text-danger" id="critical-count">{{ error_logs | selectattr('level', 'equalto', 'CRITICAL') | list | length }}</div>
<div class="stat-label">Critical</div>
</div>
</div>
<div class="col-md">
<div class="stat-card">
<div class="stat-number text-danger" id="error-count">{{ error_logs | selectattr('level', 'equalto', 'ERROR') | list | length }}</div>
<div class="stat-label">Errors</div>
</div>
</div>
<div class="col-md">
<div class="stat-card">
<div class="stat-number text-warning" id="warning-count">{{ error_logs | selectattr('level', 'equalto', 'WARNING') | list | length }}</div>
<div class="stat-label">Warnings</div>
</div>
</div>
<div class="col-md">
<div class="stat-card">
<div class="stat-number text-info" id="total-count">{{ error_logs | length }}</div>
<div class="stat-label">Total Logs</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="filter-form">
<form method="GET" action="{{ url_for('auth.view_error_logs') }}">
<div class="row align-items-end">
<div class="col-md-3">
<label for="start_date" class="form-label">Start Date:</label>
<input type="date" class="form-control" id="start_date" name="start_date" value="{{ start_date }}">
</div>
<div class="col-md-3">
<label for="end_date" class="form-label">End Date:</label>
<input type="date" class="form-control" id="end_date" name="end_date" value="{{ end_date }}">
</div>
<div class="col-md-3">
<label for="level" class="form-label">Log Level:</label>
<select class="form-select" id="level" name="level">
<option value="">All Levels</option>
{% for level in available_levels %}
<option value="{{ level }}" {% if level == level_filter %}selected{% endif %}>{{ level }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary">Apply Filters</button>
<a href="{{ url_for('auth.view_error_logs') }}" class="btn btn-outline-secondary">Reset</a>
</div>
</div>
</form>
</div>
<!-- Error Logs Table -->
<div class="card">
<div class="card-body">
<table id="errorLogsTable" class="table table-striped table-bordered" style="width:100%">
<thead>
<tr>
<th>Timestamp</th>
<th>Level</th>
<th>Logger</th>
<th>Message</th>
<th>User</th>
<th>IP Address</th>
<th>Request ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for log in error_logs %}
<tr>
<td data-order="{{ log.timestamp.strftime('%Y%m%d%H%M%S') }}">
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td>
<span class="log-level-{{ log.level }}">{{ log.level }}</span>
</td>
<td>{{ log.logger_name or 'N/A' }}</td>
<td>
<div class="log-message" title="{{ log.message }}">{{ log.message }}</div>
{% if log.exception %}
<small class="exception-toggle" onclick="toggleException({{ log.id }})">
View Exception
</small>
<div id="exception-{{ log.id }}" class="exception-details" style="display: none;">
{{ log.exception }}
</div>
{% endif %}
</td>
<td>{{ log.user_id or 'N/A' }}</td>
<td>{{ log.remote_addr or 'N/A' }}</td>
<td>{{ log.request_id or 'N/A' }}</td>
<td>
<button class="btn btn-sm btn-outline-info" onclick="viewLogDetail({{ log.id }})">
Details
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Clear Logs Modal -->
<div class="modal fade" id="clearLogsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Clear Old Error Logs</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>This will permanently delete error logs older than the specified number of days.</p>
<div class="mb-3">
<label for="daysToKeep" class="form-label">Keep logs for (days):</label>
<input type="number" class="form-control" id="daysToKeep" value="30" min="1" max="365">
<div class="form-text">Logs older than this many days will be deleted.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="clearOldLogs()">Clear Logs</button>
</div>
</div>
</div>
</div>
<!-- Log Detail Modal -->
<div class="modal fade" id="logDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Error Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="logDetailContent">
<!-- Content loaded via JavaScript -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.buttons.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.bootstrap5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.html5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
<script>
$(document).ready(function() {
// Initialize DataTable
var table = $('#errorLogsTable').DataTable({
pageLength: 25,
lengthMenu: [[25, 50, 100, 200, -1], [25, 50, 100, 200, "All"]],
order: [[0, 'desc']], // Sort by timestamp descending
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
'<"row"<"col-sm-12"tr>>' +
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
buttons: [
{
extend: 'csv',
text: 'Export CSV',
className: 'btn btn-secondary btn-sm',
filename: 'error_logs_' + new Date().toISOString().split('T')[0]
}
],
language: {
search: "Search logs:",
lengthMenu: "Show _MENU_ logs per page",
info: "Showing _START_ to _END_ of _TOTAL_ logs",
paginate: {
first: "First",
last: "Last",
next: "Next",
previous: "Previous"
}
}
});
// Stats card click events
$('#debug-count').closest('.stat-card').on('click', function() {
table.column(1).search('Debug').draw();
});
$('#info-count').closest('.stat-card').on('click', function() {
table.column(1).search('Info').draw();
});
$('#critical-count').closest('.stat-card').on('click', function() {
table.column(1).search('CRITICAL').draw();
});
$('#error-count').closest('.stat-card').on('click', function() {
table.column(1).search('ERROR').draw();
});
$('#warning-count').closest('.stat-card').on('click', function() {
table.column(1).search('WARNING').draw();
});
$('#total-count').closest('.stat-card').on('click', function() {
table.column(1).search('').draw();
});
// Refresh button
$('#refresh-logs').on('click', function() {
location.reload();
});
});
function toggleException(logId) {
var element = document.getElementById('exception-' + logId);
if (element.style.display === 'none') {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
}
function viewLogDetail(logId) {
fetch(`{{ url_for('auth.view_error_log_detail', log_id=0) }}`.replace('0', logId))
.then(response => response.json())
.then(data => {
var content = `
<div class="row">
<div class="col-md-6"><strong>ID:</strong> ${data.id}</div>
<div class="col-md-6"><strong>Level:</strong> <span class="log-level-${data.level}">${data.level}</span></div>
</div>
<div class="row mt-2">
<div class="col-md-6"><strong>Logger:</strong> ${data.logger_name || 'N/A'}</div>
<div class="col-md-6"><strong>Timestamp:</strong> ${new Date(data.timestamp).toLocaleString()}</div>
</div>
<div class="row mt-2">
<div class="col-md-6"><strong>User ID:</strong> ${data.user_id || 'N/A'}</div>
<div class="col-md-6"><strong>IP Address:</strong> ${data.remote_addr || 'N/A'}</div>
</div>
<div class="row mt-2">
<div class="col-md-6"><strong>Request ID:</strong> ${data.request_id || 'N/A'}</div>
<div class="col-md-6"><strong>File:</strong> ${data.pathname || 'N/A'}${data.lineno ? ':' + data.lineno : ''}</div>
</div>
<div class="mt-3">
<strong>Message:</strong>
<div class="exception-details mt-1">${data.message}</div>
</div>
${data.exception ? `
<div class="mt-3">
<strong>Exception:</strong>
<div class="exception-details mt-1">${data.exception}</div>
</div>
` : ''}
`;
document.getElementById('logDetailContent').innerHTML = content;
new bootstrap.Modal(document.getElementById('logDetailModal')).show();
})
.catch(error => {
alert('Error loading log details: ' + error);
});
}
function clearOldLogs() {
var daysToKeep = document.getElementById('daysToKeep').value;
if (!confirm(`Are you sure you want to delete all error logs older than ${daysToKeep} days? This action cannot be undone.`)) {
return;
}
fetch(`{{ url_for('auth.clear_error_logs') }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': $('meta[name=csrf-token]').attr('content')
},
body: JSON.stringify({
days_to_keep: parseInt(daysToKeep)
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
alert('Error clearing logs: ' + error);
});
// Close modal
bootstrap.Modal.getInstance(document.getElementById('clearLogsModal')).hide();
}
</script>
{% endblock %}

133
templates/auth/login.html Normal file
View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-header">
<h3>Login</h3>
</div>
<div class="card-body">
{% if error_message %}
<div class="alert alert-danger">{{ error_message }}</div>
{% endif %}
<form id="loginForm" method="POST" action="">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
{% for error in form.password.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<div class="mb-3 form-check">
{{ form.remember(class="form-check-input") }}
{{ form.remember.label(class="form-check-label") }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
{% if allow_registration %}
<div class="card-footer">
<small>Need an account? <a href="{{ url_for('auth.register') }}">Sign up now</a></small>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Error Modal -->
<div class="modal fade" id="loginErrorModal" tabindex="-1" aria-labelledby="loginErrorModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginErrorModalLabel">Login Error</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="loginErrorModalBody">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% include 'auth/mfa_modal.html' %}
{% endblock %}
{% block scripts %}
<script>
// Get CSRF token from the rendered template
const csrfToken = '{{ csrf_token() }}';
document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
fetch('{{ url_for("auth.login") }}', {
method: 'POST',
body: new FormData(this),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrfToken
}
})
.then(response => response.json().then(data => ({status: response.status, body: data})))
.then(({status, body}) => {
if (body.require_mfa) {
const modal = new bootstrap.Modal(document.getElementById('mfaModal'));
modal.show();
} else if (body.require_mfa_setup) {
// Redirect to MFA setup for required users
window.location.href = '{{ url_for("auth.setup_mfa") }}';
} else if (body.redirect) {
window.location.href = body.redirect;
} else if (body.error) {
document.getElementById('loginErrorModalBody').textContent = body.error;
const errorModal = new bootstrap.Modal(document.getElementById('loginErrorModal'));
errorModal.show();
}
})
.catch(error => {
document.getElementById('loginErrorModalBody').textContent = 'An unexpected error occurred.';
const errorModal = new bootstrap.Modal(document.getElementById('loginErrorModal'));
errorModal.show();
});
});
document.getElementById('mfaForm').addEventListener('submit', function(e) {
e.preventDefault();
fetch('{{ url_for("auth.verify_mfa") }}', {
method: 'POST',
body: new FormData(this),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.redirect) {
window.location.href = data.redirect;
} else {
alert('Invalid MFA code');
}
})
.catch(error => {
alert('Failed to verify MFA code');
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>Company Management</h2>
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title">Companies</h5>
<a href="{{ url_for('auth.create_company') }}" class="btn btn-primary">Create New Company</a>
</div>
<div class="table-responsive mt-3">
<table class="table table-striped" id="companiesTable">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for company in companies %}
<tr>
<td>{{ company.name }}</td>
<td>{{ company.description }}</td>
<td>{{ company.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('auth.edit_company', company_id=company.id) }}" class="btn btn-sm btn-primary">Edit</a>
<a href="{{ url_for('auth.company_users', company_id=company.id) }}" class="btn btn-sm btn-info">Manage Users</a>
<a href="{{ url_for('auth.company_api_keys', company_id=company.id) }}" class="btn btn-sm btn-secondary">Sites (API Key)</a>
<button type="button" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#deleteCompanyModal{{ company.id }}">
Delete
</button>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteCompanyModal{{ company.id }}" tabindex="-1" aria-labelledby="deleteCompanyModalLabel{{ company.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteCompanyModalLabel{{ company.id }}">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the company: <strong>{{ company.name }}</strong>?
<p class="text-danger mt-2">This action cannot be undone and will remove this company from the system. The company's API keys will be deleted and all users will be removed from this company (but users will not be deleted from the system).</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form action="{{ url_for('auth.delete_company', company_id=company.id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Delete Company</button>
</form>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<script>
$(document).ready(function() {
$('#companiesTable').DataTable({
"pageLength": 10,
"order": [[0, "asc"]]
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,839 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
{% endblock %}
{% block title %}Manage Users{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>Manage Users</h2>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Create New User</h5>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-4 mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="col-md-4 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="col-md-4 mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select" id="role" name="role" required>
<option value="User">User</option>
{% if current_user.role == 'GlobalAdmin' %}
<option value="Admin">Admin</option>
<option value="GlobalAdmin">Global Admin</option>
{% endif %}
</select>
</div>
<div class="col-md-4 mb-3">
<div class="form-check mt-4">
<input type="checkbox" class="form-check-input" id="is_active" name="is_active">
<label class="form-check-label" for="is_active">Account Active</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Create User</button>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Existing Users</h5>
<div class="table-responsive">
<table class="table table-striped" id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Companies</th>
<th>Status</th>
<th>2FA</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
{% if current_user.role == 'GlobalAdmin' and user.id != current_user.id %}
<!-- Role dropdown for Global Admins -->
<div class="dropdown">
<button class="btn btn-sm dropdown-toggle
{% if user.role == 'GlobalAdmin' %}btn-danger
{% elif user.role == 'Admin' %}btn-warning
{% else %}btn-info{% endif %}"
type="button" data-bs-toggle="dropdown">
{{ user.role }}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="changeUserRole({{ user.id }}, 'User')">User</a></li>
<li><a class="dropdown-item" href="#" onclick="changeUserRole({{ user.id }}, 'Admin')">Admin</a></li>
<li><a class="dropdown-item" href="#" onclick="changeUserRole({{ user.id }}, 'GlobalAdmin')">Global Admin</a></li>
</ul>
</div>
{% else %}
<!-- Static display for non-Global Admins or current user -->
{% if user.role == 'GlobalAdmin' %}
<span class="badge bg-danger">Global Admin</span>
{% elif user.role == 'Admin' %}
<span class="badge bg-warning">Admin</span>
{% else %}
<span class="badge bg-info">User</span>
{% endif %}
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#companiesModal{{ user.id }}">
{% if user.filtered_companies %}
{{ user.filtered_companies|length }} Companies
{% else %}
No Companies
{% endif %}
</button>
<!-- Company Management Modal -->
<div class="modal fade" id="companiesModal{{ user.id }}" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Manage Companies for {{ user.username }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Current Companies -->
<h6>Current Companies:</h6>
{% if current_user.role == 'Admin' and user.companies|length > user.filtered_companies|length %}
<div class="alert alert-info" role="alert">
<small><i class="fas fa-info-circle"></i>
This user belongs to {{ user.companies|length }} companies total, but you can only see and manage {{ user.filtered_companies|length }} companies that you also have access to.</small>
</div>
{% endif %}
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>Company</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="currentCompanies{{ user.id }}">
{% if user.filtered_companies %}
{% for uc in user.filtered_companies %}
<tr id="company-row-{{ user.id }}-{{ uc.company_id }}">
<td>{{ uc.company.name }}</td>
<td>
{% if current_user.role == 'GlobalAdmin' or (current_user.role == 'Admin' and user.role not in ['Admin', 'GlobalAdmin']) %}
<select class="form-select form-select-sm"
onchange="changeCompanyRole({{ user.id }}, {{ uc.company_id }}, this.value)">
<option value="User" {% if uc.role == 'User' %}selected{% endif %}>User</option>
{% if current_user.role == 'GlobalAdmin' %}
<option value="CompanyAdmin" {% if uc.role == 'CompanyAdmin' %}selected{% endif %}>Company Admin</option>
{% endif %}
</select>
{% else %}
<span class="badge {% if uc.role == 'CompanyAdmin' %}bg-warning{% else %}bg-info{% endif %}">
{{ uc.role }}
</span>
{% endif %}
</td>
<td>
{% if current_user.role == 'GlobalAdmin' or (current_user.role == 'Admin' and user.role not in ['Admin', 'GlobalAdmin']) %}
<button class="btn btn-sm btn-danger"
onclick="removeFromCompany({{ user.id }}, {{ uc.company_id }})">
Remove
</button>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr id="no-companies-row-{{ user.id }}">
<td colspan="3" class="text-muted text-center">
User is not associated with any companies.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Add to Company Section -->
{% if current_user.role == 'GlobalAdmin' or (current_user.role == 'Admin' and user.role not in ['Admin', 'GlobalAdmin']) %}
<hr>
<h6>Add to Company:</h6>
<div class="row mb-3">
<div class="col-md-6">
<select class="form-select" id="addCompanySelect{{ user.id }}">
<option value="">Select a company...</option>
<!-- Options will be populated by JavaScript -->
</select>
</div>
<div class="col-md-3">
<select class="form-select" id="addCompanyRole{{ user.id }}">
<option value="User">User</option>
{% if current_user.role == 'GlobalAdmin' %}
<option value="CompanyAdmin">Company Admin</option>
{% endif %}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-primary" onclick="addToCompany({{ user.id }})">
Add to Company
</button>
</div>
</div>
<!-- Create New Company Section (GlobalAdmin only) -->
{% if current_user.role == 'GlobalAdmin' %}
<div class="border-top pt-3">
<h6>Create New Company:</h6>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control"
id="newCompanyName{{ user.id }}"
placeholder="Company name...">
</div>
<div class="col-md-3">
<select class="form-select" id="newCompanyRole{{ user.id }}">
<option value="User">User</option>
<option value="CompanyAdmin">Company Admin</option>
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success" onclick="createAndAddCompany({{ user.id }})">
Create & Add
</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</td>
<td>
<span class="badge {% if user.is_active %}bg-success{% else %}bg-danger{% endif %}">
{{ 'Active' if user.is_active else 'Inactive' }}
</span>
</td>
<td>
<span class="badge {% if user.mfa_enabled %}bg-success{% else %}bg-secondary{% endif %}">
{{ 'Enabled' if user.mfa_enabled else 'Disabled' }}
</span>
{% if current_user.role == 'GlobalAdmin' %}
<br><small class="text-muted">
Required:
{% if user.mfa_required is none %}
<span class="badge bg-info">Inherit</span>
{% elif user.mfa_required %}
<span class="badge bg-warning">Yes</span>
{% else %}
<span class="badge bg-success">No</span>
{% endif %}
</small>
<br>
<div class="btn-group btn-group-sm mt-1" role="group">
<button type="button" class="btn btn-outline-info btn-xs"
onclick="updateMfaRequirement({{ user.id }}, null)"
{% if user.mfa_required is none %}disabled{% endif %}>
Inherit
</button>
<button type="button" class="btn btn-outline-warning btn-xs"
onclick="updateMfaRequirement({{ user.id }}, true)"
{% if user.mfa_required == true %}disabled{% endif %}>
Required
</button>
<button type="button" class="btn btn-outline-success btn-xs"
onclick="updateMfaRequirement({{ user.id }}, false)"
{% if user.mfa_required == false %}disabled{% endif %}>
Not Required
</button>
</div>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
{% if current_user.is_global_admin() or (current_user.role == 'Admin' and user.role != 'GlobalAdmin' and user.role != 'Admin') %}
<button type="button" class="btn btn-sm btn-warning"
onclick="toggleUserStatus({{ user.id }}, this)"
{% if user.id == current_user.id %}disabled title="You cannot deactivate your own account"{% endif %}>
{{ 'Deactivate' if user.is_active else 'Activate' }}
</button>
<button type="button" class="btn btn-sm btn-info"
onclick="showResetPasswordModal({{ user.id }})"
{% if user.id == current_user.id %}disabled title="Use your profile page to change your own password"{% endif %}>
Reset Password
</button>
{% if user.mfa_secret %}
<form action="{{ url_for('auth.admin_reset_mfa', user_id=user.id) }}"
method="POST" style="display: inline;"
onsubmit="return confirm('Are you sure you want to reset 2FA for this user? They will need to set it up again.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-secondary"
{% if user.id == current_user.id %}disabled title="Use your profile page to manage your own 2FA"{% endif %}>
Reset 2FA
</button>
</form>
{% endif %}
{% if user.id != current_user.id %}
<form action="{{ url_for('auth.delete_user', user_id=user.id) }}"
method="POST" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this user?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{% endif %}
{% else %}
<button type="button" class="btn btn-sm btn-secondary" disabled title="You don't have permission to manage this user">
No Actions Available
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toasts will be inserted here dynamically -->
</div>
<!-- Reset Password Modal -->
<div class="modal fade" id="resetPasswordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Reset User Password</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="resetPasswordForm" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control" id="new_password" name="new_password" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">Reset Password</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<script>
// Get current user role for permission checks
const currentUserRole = '{{ current_user.role }}';
// Function to create and display toast notifications
function createToast(type, message) {
const toastContainer = document.querySelector('.toast-container');
// Create toast element
const toastEl = document.createElement('div');
toastEl.className = `toast align-items-center text-white bg-${type} border-0`;
toastEl.setAttribute('role', 'alert');
toastEl.setAttribute('aria-live', 'assertive');
toastEl.setAttribute('aria-atomic', 'true');
// Create toast content
const toastContent = `
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
toastEl.innerHTML = toastContent;
toastContainer.appendChild(toastEl);
// Create Bootstrap toast instance
const toast = new bootstrap.Toast(toastEl, {
delay: 5000,
autohide: true
});
// Remove toast element after it's hidden
toastEl.addEventListener('hidden.bs.toast', () => {
toastEl.remove();
});
return toast;
}
function toggleUserStatus(userId, button) {
const baseUrl = '{{ url_for("auth.toggle_user_status", user_id=1) }}'.replace('/1', '/' + userId);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => {
if (response.ok) {
return response.json().then(data => {
// Show success message
const toast = createToast('success', 'User status updated successfully');
toast.show();
setTimeout(() => location.reload(), 1000);
});
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to toggle user status');
});
}
})
.catch(error => {
console.error('Error:', error);
const toast = createToast('danger', error.message || 'Failed to toggle user status');
toast.show();
});
}
function showResetPasswordModal(userId) {
const modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
const baseUrl = '{{ url_for("auth.reset_user_password", user_id=1) }}'.replace('/1', '/' + userId);
document.getElementById('resetPasswordForm').action = baseUrl;
modal.show();
}
// Add function to handle changing user roles
function changeUserRole(userId, role) {
if (confirm(`Are you sure you want to change this user's role to ${role}?`)) {
const baseUrl = '{{ url_for("auth.change_user_role", user_id=1) }}'.replace('/1', '/' + userId);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ role: role })
})
.then(response => {
if (response.ok) {
location.reload();
} else {
response.json().then(data => {
alert(data.error || 'Failed to change user role');
}).catch(() => {
alert('Failed to change user role');
});
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to change user role');
});
}
}
// Add function to handle MFA requirement updates
function updateMfaRequirement(userId, mfaRequired) {
const actionText = mfaRequired === null ? 'inherit from global setting' :
mfaRequired ? 'require MFA' : 'not require MFA';
if (confirm(`Are you sure you want to set this user to ${actionText}?`)) {
const baseUrl = '{{ url_for("auth.update_user_mfa_requirement", user_id=1) }}'.replace('/1', '/' + userId);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ mfa_required: mfaRequired })
})
.then(response => {
if (response.ok) {
return response.json().then(data => {
const toast = createToast('success', data.message);
toast.show();
setTimeout(() => location.reload(), 1000);
});
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to update MFA requirement');
});
}
})
.catch(error => {
console.error('Error:', error);
const toast = createToast('danger', error.message || 'Failed to update MFA requirement');
toast.show();
});
}
}
// Company management functions
function loadAvailableCompanies(userId) {
const select = document.getElementById(`addCompanySelect${userId}`);
// If the select element doesn't exist (e.g., Admin viewing another Admin), skip loading
if (!select) {
console.log(`Add Company select not found for user ${userId} - user likely doesn't have permission to manage this user's companies`);
return;
}
fetch('{{ url_for("auth.get_companies_for_user_management") }}')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(data => {
// Clear existing options except the first one
select.innerHTML = '<option value="">Select a company...</option>';
// Handle the response format (companies array is in data.companies)
const companies = data.companies || data || [];
companies.forEach(company => {
const option = document.createElement('option');
option.value = company.id;
option.textContent = company.name;
select.appendChild(option);
});
console.log(`Loaded ${companies.length} companies for user ${userId}`);
})
.catch(error => {
console.error('Error loading companies:', error);
const toast = createToast('danger', `Failed to load available companies: ${error.message}`);
toast.show();
});
}
function addToCompany(userId) {
const companySelect = document.getElementById(`addCompanySelect${userId}`);
const roleSelect = document.getElementById(`addCompanyRole${userId}`);
const companyId = companySelect.value;
const role = roleSelect.value;
if (!companyId) {
const toast = createToast('warning', 'Please select a company');
toast.show();
return;
}
const baseUrl = '{{ url_for("auth.add_user_to_company", user_id=1) }}'.replace('/1', '/' + userId);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
company_id: parseInt(companyId),
role: role
})
})
.then(response => {
if (response.ok) {
return response.json();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to add user to company');
});
}
})
.then(data => {
const toast = createToast('success', data.message);
toast.show();
// Add new row to the current companies table
const tbody = document.getElementById(`currentCompanies${userId}`);
if (tbody) {
// Remove "no companies" row if it exists
const noCompaniesRow = document.getElementById(`no-companies-row-${userId}`);
if (noCompaniesRow) {
noCompaniesRow.remove();
}
const newRow = document.createElement('tr');
newRow.id = `company-row-${userId}-${companyId}`;
newRow.innerHTML = `
<td>${companySelect.options[companySelect.selectedIndex].text}</td>
<td>
<select class="form-select form-select-sm"
onchange="changeCompanyRole(${userId}, ${companyId}, this.value)">
<option value="User" ${role === 'User' ? 'selected' : ''}>User</option>
${currentUserRole === 'GlobalAdmin' ? `<option value="CompanyAdmin" ${role === 'CompanyAdmin' ? 'selected' : ''}>Company Admin</option>` : ''}
</select>
</td>
<td>
<button class="btn btn-sm btn-danger"
onclick="removeFromCompany(${userId}, ${companyId})">
Remove
</button>
</td>
`;
tbody.appendChild(newRow);
}
// Reset the form
companySelect.value = '';
roleSelect.value = 'User';
// Remove the selected company from the dropdown
const selectedOption = companySelect.querySelector(`option[value="${companyId}"]`);
if (selectedOption) {
selectedOption.remove();
}
})
.catch(error => {
console.error('Error:', error);
const toast = createToast('danger', error.message);
toast.show();
});
}
function removeFromCompany(userId, companyId) {
if (!confirm('Are you sure you want to remove this user from the company?')) {
return;
}
const baseUrl = '{{ url_for("auth.remove_user_from_company", user_id=1, company_id=1) }}'.replace('/1/companies/1/', `/${userId}/companies/${companyId}/`);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => {
if (response.ok) {
return response.json();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to remove user from company');
});
}
})
.then(data => {
const toast = createToast('success', data.message);
toast.show();
// Remove the row from the table
const row = document.getElementById(`company-row-${userId}-${companyId}`);
if (row) {
const companyName = row.querySelector('td').textContent;
row.remove();
// Check if this was the last company row
const tbody = document.getElementById(`currentCompanies${userId}`);
const remainingRows = tbody.querySelectorAll('tr:not([id^="no-companies-row"])');
if (remainingRows.length === 0) {
// Add the "no companies" row back
const noCompaniesRow = document.createElement('tr');
noCompaniesRow.id = `no-companies-row-${userId}`;
noCompaniesRow.innerHTML = `
<td colspan="3" class="text-muted text-center">
User is not associated with any companies.
</td>
`;
tbody.appendChild(noCompaniesRow);
}
// Add the company back to the dropdown
const select = document.getElementById(`addCompanySelect${userId}`);
const option = document.createElement('option');
option.value = companyId;
option.textContent = companyName;
select.appendChild(option);
}
})
.catch(error => {
console.error('Error:', error);
const toast = createToast('danger', error.message);
toast.show();
});
}
function changeCompanyRole(userId, companyId, newRole) {
const baseUrl = '{{ url_for("auth.change_user_company_role", user_id=1, company_id=1) }}'.replace('/1/companies/1/', `/${userId}/companies/${companyId}/`);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ role: newRole })
})
.then(response => {
if (response.ok) {
return response.json();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to change company role');
});
}
})
.then(data => {
const toast = createToast('success', data.message);
toast.show();
})
.catch(error => {
console.error('Error:', error);
const toast = createToast('danger', error.message);
toast.show();
// Revert the select value on error
location.reload();
});
}
function createAndAddCompany(userId) {
const nameInput = document.getElementById(`newCompanyName${userId}`);
const roleSelect = document.getElementById(`newCompanyRole${userId}`);
const companyName = nameInput.value.trim();
const role = roleSelect.value;
if (!companyName) {
const toast = createToast('warning', 'Please enter a company name');
toast.show();
return;
}
fetch('{{ url_for("auth.create_company_from_manage_users") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
name: companyName,
user_id: userId,
role: role
})
})
.then(response => {
if (response.ok) {
return response.json();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to create company');
});
}
})
.then(data => {
const toast = createToast('success', data.message);
toast.show();
// Add new row to the current companies table
const tbody = document.getElementById(`currentCompanies${userId}`);
if (tbody) {
// Remove "no companies" row if it exists
const noCompaniesRow = document.getElementById(`no-companies-row-${userId}`);
if (noCompaniesRow) {
noCompaniesRow.remove();
}
const newRow = document.createElement('tr');
newRow.id = `company-row-${userId}-${data.company_id}`;
newRow.innerHTML = `
<td>${companyName}</td>
<td>
<select class="form-select form-select-sm"
onchange="changeCompanyRole(${userId}, ${data.company_id}, this.value)">
<option value="User" ${role === 'User' ? 'selected' : ''}>User</option>
${currentUserRole === 'GlobalAdmin' ? `<option value="CompanyAdmin" ${role === 'CompanyAdmin' ? 'selected' : ''}>Company Admin</option>` : ''}
</select>
</td>
<td>
<button class="btn btn-sm btn-danger"
onclick="removeFromCompany(${userId}, ${data.company_id})">
Remove
</button>
</td>
`;
tbody.appendChild(newRow);
} else {
console.warn(`Table body currentCompanies${userId} not found`);
// Just reload the page if we can't update the table
setTimeout(() => location.reload(), 1000);
}
// Reset the form
nameInput.value = '';
roleSelect.value = 'User';
// Add the new company to all dropdown lists
document.querySelectorAll('[id^="addCompanySelect"]').forEach(select => {
const option = document.createElement('option');
option.value = data.company_id;
option.textContent = companyName;
select.appendChild(option);
});
})
.catch(error => {
console.error('Error:', error);
const toast = createToast('danger', error.message);
toast.show();
});
}
// Load available companies when modal is opened
document.addEventListener('DOMContentLoaded', function() {
// Add event listeners for when company modals are shown
document.querySelectorAll('[id^="companiesModal"]').forEach(modal => {
modal.addEventListener('shown.bs.modal', function() {
const userId = this.id.replace('companiesModal', '');
loadAvailableCompanies(userId);
});
});
});
$(document).ready(function() {
$('#usersTable').DataTable({
"pageLength": 10,
"order": [[0, "asc"]]
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,22 @@
<!-- MFA Verification Modal -->
<div class="modal fade" id="mfaModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Two-Factor Authentication</h5>
</div>
<form id="mfaForm" method="POST" action="{{ url_for('auth.verify_mfa') }}">
<div class="modal-body">
<div class="mb-3">
<label for="mfa_code" class="form-label">Enter the 6-digit code from your authenticator app</label>
<input type="text" class="form-control" id="mfa_code" name="mfa_code"
required pattern="[0-9]{6}" maxlength="6">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Verify</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-header">
<h3>Two-Factor Authentication Setup</h3>
{% if forced_setup %}
<div class="alert alert-warning mb-0 mt-2">
<strong>Required:</strong> You must set up two-factor authentication to continue using your account.
</div>
{% endif %}
</div>
<div class="card-body">
<ol class="mb-4">
<li>Install an authenticator app like Google Authenticator or Microsoft Authenticator on your phone</li>
<li>Scan the QR code below or manually enter the secret key in your authenticator app</li>
<li>Enter the 6-digit code from your app to verify setup</li>
</ol>
<div class="text-center mb-4">
<img src="{{ qr_code }}" alt="QR Code" class="img-fluid" style="max-width: 200px;">
</div>
<div class="accordion mb-4">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button"
data-bs-toggle="collapse" data-bs-target="#manualSetup">
Can't scan the QR code?
</button>
</h2>
<div id="manualSetup" class="accordion-collapse collapse">
<div class="accordion-body">
<p><strong>Secret Key:</strong> <code>{{ secret }}</code></p>
<p class="text-muted small">Enter this secret key manually in your authenticator app if you can't scan the QR code.</p>
</div>
</div>
</div>
</div>
<form method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="verification_code" class="form-label">Verification Code</label>
<input type="text" class="form-control" id="verification_code"
name="verification_code" required pattern="[0-9]{6}" maxlength="6">
</div>
<button type="submit" class="btn btn-primary">Verify and Enable 2FA</button>
{% if not forced_setup %}
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary">Cancel</a>
{% else %}
<a href="{{ url_for('auth.logout') }}" class="btn btn-secondary">Logout</a>
{% endif %}
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-header">
<h3>Profile Settings</h3>
</div>
<div class="card-body">
<h5>Account Information</h5>
<p><strong>Username:</strong> {{ current_user.username }}</p>
<p><strong>Email:</strong> {{ current_user.email }}</p>
<p><strong>Role:</strong> {{ current_user.role }}</p>
<hr>
<h5>Two-Factor Authentication</h5>
<p>Status:
<span class="badge {% if current_user.mfa_enabled %}bg-success{% else %}bg-warning{% endif %}">
{{ "Enabled" if current_user.mfa_enabled else "Disabled" }}
</span>
{% if current_user.is_mfa_required() %}
<span class="badge bg-info ms-2">Required</span>
{% endif %}
</p>
<div class="btn-group" role="group">
{% if not current_user.mfa_enabled %}
<a href="{{ url_for('auth.setup_mfa') }}" class="btn btn-primary">Setup 2FA</a>
{% endif %}
{% if current_user.mfa_secret %}
{% if current_user.mfa_enabled and current_user.is_mfa_required() and current_user.role != 'GlobalAdmin' %}
<!-- User cannot disable MFA when it's required, unless they're GlobalAdmin -->
<button type="button" class="btn btn-warning" disabled
title="MFA is required and cannot be disabled. Contact your administrator.">
Disable 2FA (Required)
</button>
{% else %}
<!-- User can toggle MFA -->
<form method="POST" action="{{ url_for('auth.toggle_mfa') }}" style="display: inline;">
{{ mfa_action_form.hidden_tag() }}
<button type="submit" class="btn btn-warning">
{{ "Disable" if current_user.mfa_enabled else "Enable" }} 2FA
</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('auth.reset_mfa') }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to reset 2FA? You will need to set it up again.');">
{{ mfa_action_form.hidden_tag() }}
<button type="submit" class="btn btn-danger">Reset 2FA</button>
</form>
{% endif %}
</div>
{% if current_user.is_mfa_required() and current_user.role != 'GlobalAdmin' %}
<div class="alert alert-info mt-3">
<small><strong>Note:</strong> MFA is required for your account and cannot be disabled. Contact your administrator if you need to disable MFA.</small>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-header">
<h3>Register</h3>
</div>
<div class="card-body">
<form method="POST" action="">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
{% for error in form.username.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
{% for error in form.password.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<div class="mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
{% for error in form.confirm_password.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
<div class="card-footer">
<small>Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></small>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}

170
templates/base.html Normal file
View File

@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
{% block supertitle %}{% endblock %}
<title>Domain Logon Monitor - {% block title %}{% endblock %}</title>
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/custom.css') }}" rel="stylesheet">
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.png') }}">
{% block head %}{% endblock %}
<style>
.theme-icon {
width: 1em;
height: 1em;
display: none;
}
[data-bs-theme="dark"] .theme-icon-dark {
display: inline;
}
[data-bs-theme="light"] .theme-icon-light {
display: inline;
}
.dark-theme {
background-color: #212529;
color: #f8f9fa;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('frontend.index') }}">
<img src="{{ url_for('static', filename='img/favicon.png') }}" style="width: 30px;">
<!-- Performance icons created by Uniconlabs - Flaticon ( www.flaticon.com/free-icons/performance) -->
Domain Logon Monitor</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if current_user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dashboardDropdown" role="button" data-bs-toggle="dropdown">
Dashboard
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('frontend.dashboard') }}">Login Events</a></li>
<li><a class="dropdown-item" href="{{ url_for('frontend.time_spent_report') }}">Time Spent Report</a></li>
</ul>
</li>
{% if current_user.role == 'Admin' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.manage_users') }}">Manage Users</a>
</li>
{% endif %}
{% if current_user.role == 'GlobalAdmin' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
Admin
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('auth.manage_users') }}">Manage Users</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.manage_companies') }}">Companies</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.admin_settings') }}">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.view_error_logs') }}">Error Logs</a></li>
</ul>
</li>
{% endif %}
{# Companies dropdown for users who belong to multiple companies or company admins #}
{% if current_user.companies|length > 0 %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="companyDropdown" role="button" data-bs-toggle="dropdown">
Companies
</a>
<ul class="dropdown-menu">
{% for uc in current_user.companies %}
<li>
<a class="dropdown-item" href="{{ url_for('frontend.dashboard', company_id=uc.company.id) }}">
{{ uc.company.name }}
</a>
</li>
{% if uc.role == 'CompanyAdmin' %}
<li>
<a class="dropdown-item ps-4" href="{{ url_for('auth.company_users', company_id=uc.company.id) }}">
<small>Manage Users</small>
</a>
</li>
<li>
<a class="dropdown-item ps-4" href="{{ url_for('auth.company_api_keys', company_id=uc.company.id) }}">
<small>Sites (API Key)</small>
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endfor %}
</ul>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.profile') }}">Profile</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
{% if allow_registration %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
</li>
{% endif %}
{% endif %}
<li class="nav-item">
<button class="btn btn-link nav-link" id="theme-toggle">
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
</svg>
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
</svg>
</button>
</li>
</ul>
</div>
</div>
</nav>
<main>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} mt-3">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<script src="{{ url_for('static', filename='js/jquery-3.6.0.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
{% block scripts %}{% endblock %}
<script>
// Theme toggler
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
const storedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-bs-theme', storedTheme);
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-bs-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', newTheme);
localStorage.setItem('theme', newTheme);
});
});
</script>
</body>
<!-- <a href="https://www.flaticon.com/free-icons/performance" title="performance icons">Performance icons created by Uniconlabs - Flaticon</a> -->
</html>

57
templates/error.html Normal file
View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block supertitle %}
<title>Opsiee {{status_code}} Error</title>
{% endblock %}
{% block content %}
<div class="container mt-4">
<h1 style="color: red; font-weight: bold;">{{status_code}} Error</h1>
<p style="color: deeppink;font-weight: bold;">{{description}}</p>
<br></br>
{% if current_user.is_authenticated %}
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Quick Links:</h5>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a href="{{ url_for('frontend.dashboard') }}" class="text-decoration-none">View Dashboard</a>
</li>
<li class="list-group-item">
<a href="{{ url_for('frontend.time_spent_report') }}" class="text-decoration-none">Time Spent Report</a>
</li>
{% if current_user.role == 'Admin' or current_user.role == 'GlobalAdmin' %}
<br></br><h5 class="card-title">Management:</h5>
<li class="list-group-item">
<a href="{{ url_for('auth.manage_users') }}" class="text-decoration-none">Manage Users</a>
</li>
{% endif %}
{% if current_user.role == 'GlobalAdmin' %}
<li class="list-group-item">
<a href="{{ url_for('auth.manage_companies') }}" class="text-decoration-none">Manage Companies</a>
</li>
<li class="list-group-item">
<a href="{{ url_for('auth.admin_settings') }}" class="text-decoration-none">Site Settings</a>
</li>
<li class="list-group-item">
<a href="{{ url_for('auth.view_error_logs') }}" class="text-decoration-none">View Error Logs</a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% else %}
<p class="lead">Please login {% if allow_registration %}or register {% endif %}to access the monitoring system.</p>
<div class="row mt-4">
<div class="col-md-6">
<a href="{{ url_for('auth.login') }}" class="btn btn-primary mr-2">Login</a>
{% if allow_registration %}
<a href="{{ url_for('auth.register') }}" class="btn btn-secondary">Register</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,459 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.dataTables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/colVis.dataTables.min.css') }}" rel="stylesheet">
<!-- DateRangePicker CSS -->
<link href="{{ url_for('static', filename='css/daterangepicker.css') }}" rel="stylesheet">
<!-- Custom DateRangePicker dark theme styles -->
<style>
/* Dark theme for date picker */
.daterangepicker {
background-color: #212529;
border-color: #495057;
color: #f8f9fa;
}
.daterangepicker .calendar-table {
background-color: #343a40;
border-color: #495057;
}
.daterangepicker td.available:hover,
.daterangepicker th.available:hover {
background-color: #495057;
}
.daterangepicker td.active,
.daterangepicker td.active:hover {
background-color: #0d6efd;
color: #fff;
}
/* In-between dates in the selected range - lighter blue */
.daterangepicker td.in-range {
background-color: #82b1ff; /* Lighter blue */
color: #212529; /* Darker text for better contrast */
}
.daterangepicker td.in-range:hover {
background-color: #75a7f7; /* Slightly darker when hovering */
color: #212529;
}
.daterangepicker .calendar-table .next span,
.daterangepicker .calendar-table .prev span {
border-color: #f8f9fa;
}
.daterangepicker .ranges li:hover,
.daterangepicker .ranges li.active {
background-color: #0d6efd;
color: #fff;
}
.daterangepicker .ranges li {
color: #f8f9fa;
}
.daterangepicker:after {
border-bottom-color: #212529;
}
.daterangepicker:before {
border-bottom-color: #495057;
}
/* Calendar header and weekday styling */
.daterangepicker .calendar-table th {
color: #f8f9fa;
}
/* Month name */
.daterangepicker .month {
color: #f8f9fa;
}
/* Off days (not in current month) */
.daterangepicker td.off {
color: #6c757d;
}
/* Input boxes */
.daterangepicker input.input-mini {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
/* Time picker */
.daterangepicker .calendar-time select {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
/* Apply and cancel buttons */
.daterangepicker .drp-buttons {
border-top-color: #495057;
}
.daterangepicker .drp-buttons .btn {
color: #f8f9fa;
}
/* Input fields focus */
.daterangepicker input.input-mini:focus {
border-color: #0d6efd;
}
/* Time inputs container */
.daterangepicker .calendar-time {
background-color: #343a40;
border-color: #495057;
}
/* Make the export buttons more visible */
.dt-buttons {
margin-top: 10px;
margin-bottom: 15px;
display: inline-block !important;
}
.dt-button {
margin-right: 5px;
}
/* Page title and export buttons container */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.page-title {
margin-bottom: 0;
}
/* For smaller screens */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.export-buttons {
margin-top: 10px;
}
}
/* Column visibility dropdown styles */
.dropdown-menu {
background-color: #343a40;
border-color: #495057;
}
.dropdown-item {
color: #f8f9fa;
}
.dropdown-item:hover, .dropdown-item:focus {
background-color: #495057;
color: #f8f9fa;
}
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
.form-check-label {
color: #f8f9fa;
}
#column-visibility-menu {
min-width: 200px;
}
.column-checkbox {
padding: 0.375rem 1rem;
}
</style>
{% endblock %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="page-header">
<h2 class="page-title">Login Events Dashboard</h2>
<div class="export-buttons">
<div class="btn-group me-2" role="group">
<button id="column-visibility" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Columns
</button>
<ul class="dropdown-menu" id="column-visibility-menu">
<!-- Column visibility checkboxes will be populated by JavaScript -->
</ul>
</div>
<button id="export-csv" class="btn btn-secondary btn-sm">Export CSV</button>
<button id="export-excel" class="btn btn-success btn-sm">Export Excel</button>
<button id="print-table" class="btn btn-info btn-sm">Print</button>
</div>
</div>
<div class="card mt-3 mb-3">
<div class="card-body">
<form id="dateRangeForm" method="GET" action="{{ url_for('frontend.dashboard') }}">
<div class="row align-items-end">
<div class="col-md-3">
<label for="daterange" class="form-label">Date Range:</label>
<input type="text" id="daterange" name="daterange" class="form-control"
value="{{ start_date.strftime('%Y-%m-%d %H:%M') if start_date else '' }} - {{ end_date.strftime('%Y-%m-%d %H:%M') if end_date else '' }}"/>
</div>
{% if companies %}
<div class="col-md-3">
<label for="company_id" class="form-label">Company:</label>
<select class="form-select" id="company_id" name="company_id">
<option value="">All Companies</option>
{% for company in companies %}
<option value="{{ company.id }}" {% if selected_company_id == company.id %}selected{% endif %}>
{{ company.name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-2">
<button type="submit" class="btn btn-primary">Apply Filter</button>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<table id="logsTable" class="table table-striped table-bordered" style="width:100%">
<thead>
<tr>
<th>Event Type</th>
<th>User Name</th>
<th>Computer Name</th>
<th>IP Address</th>
<th>Site</th>
<th>Timestamp</th>
{% if current_user.is_global_admin() and not selected_company_id %}
<th>Company</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.event_type }}</td>
<td>{{ log.user_name }}</td>
<td>{{ log.computer_name }}</td>
<td>{{ log.ip_address }}</td>
<td>{{ log.api_key.description if log.api_key else 'N/A' }}</td>
<td data-order="{{ log.timestamp.strftime('%Y%m%d%H%M%S') }}">
{{ log.timestamp|format_datetime }}
</td>
{% if current_user.is_global_admin() and not selected_company_id %}
<td>{{ log.company.name if log.company else 'N/A' }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<!-- DataTables Buttons JS -->
<script src="{{ url_for('static', filename='js/dataTables.buttons.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.bootstrap5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.html5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.print.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/pdfmake.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/vfs_fonts.js') }}"></script>
<!-- Moment.js -->
<script src="{{ url_for('static', filename='js/moment.min.js') }}"></script>
<!-- DateRangePicker -->
<script src="{{ url_for('static', filename='js/daterangepicker.min.js') }}"></script>
<script>
$(document).ready(function() {
$('#daterange').daterangepicker({
timePicker: true,
timePicker24Hour: true,
timePickerSeconds: false,
timePickerIncrement: 1, // Changed from 15 to 1 minute increments
autoUpdateInput: false, // Prevents auto-update so user can edit manually
locale: {
format: 'YYYY-MM-DD HH:mm',
cancelLabel: 'Clear',
applyLabel: 'Apply'
},
ranges: {
'Last 48 Hours': [moment().subtract(48, 'hours'), moment()],
'Last 7 Days': [moment().subtract(6, 'days'), moment()],
'Last 30 Days': [moment().subtract(29, 'days'), moment()],
'This Month': [moment().startOf('month'), moment().endOf('month')],
'Last Month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')]
}
});
// Handle manual input updates
$('#daterange').on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format('YYYY-MM-DD HH:mm') + ' - ' + picker.endDate.format('YYYY-MM-DD HH:mm'));
});
$('#daterange').on('cancel.daterangepicker', function(ev, picker) {
$(this).val('');
});
// Allow direct editing of the date input
$('#daterange').on('keyup', function(e) {
if(e.keyCode === 13) {
// Try to parse the input value
var parts = $(this).val().split(' - ');
if(parts.length === 2) {
var startDate = moment(parts[0], 'YYYY-MM-DD HH:mm');
var endDate = moment(parts[1], 'YYYY-MM-DD HH:mm');
if(startDate.isValid() && endDate.isValid()) {
var picker = $(this).data('daterangepicker');
picker.setStartDate(startDate);
picker.setEndDate(endDate);
}
}
}
});
var table = $('#logsTable').DataTable({
pageLength: 50,
lengthMenu: [[50, 100, 200, 500, 1000], [50, 100, 200, 500, 1000]],
order: [[{% if current_user.is_global_admin() and not selected_company_id %}6{% else %}5{% endif %}, 'desc']], // Sort by timestamp column descending
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
'<"row"<"col-sm-12"tr>>' +
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
columnDefs: [
{
targets: [2, 3], // Computer Name and IP Address columns
visible: false // Hide by default
}
],
buttons: [
{
extend: 'csv',
text: 'Export CSV',
className: 'btn btn-secondary',
filename: 'login_events_' + moment().format('YYYY-MM-DD'),
exportOptions: {
columns: ':visible'
}
},
{
extend: 'excel',
text: 'Export Excel',
className: 'btn btn-success',
filename: 'login_events_' + moment().format('YYYY-MM-DD'),
exportOptions: {
columns: ':visible'
}
},
{
extend: 'print',
text: 'Print',
className: 'btn btn-info',
exportOptions: {
columns: ':visible'
}
}
],
language: {
search: "Search records:",
lengthMenu: "Show _MENU_ records per page",
info: "Showing _START_ to _END_ of _TOTAL_ records",
paginate: {
first: "First",
last: "Last",
next: "Next",
previous: "Previous"
}
}
});
// Column names for the visibility controls
var columnNames = [
'Event Type',
'User Name',
'Computer Name',
'IP Address',
'Site',
'Timestamp'
{% if current_user.is_global_admin() and not selected_company_id %},
'Company'
{% endif %}
];
// Load saved column visibility from localStorage
function loadColumnVisibility() {
var saved = localStorage.getItem('dashboardColumnVisibility');
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.log('Error parsing saved column visibility:', e);
}
}
// Default visibility - hide Computer Name (2) and IP Address (3)
var defaultVisibility = {};
columnNames.forEach(function(name, index) {
defaultVisibility[index] = index !== 2 && index !== 3;
});
return defaultVisibility;
}
// Save column visibility to localStorage
function saveColumnVisibility(visibility) {
localStorage.setItem('dashboardColumnVisibility', JSON.stringify(visibility));
}
// Apply saved column visibility to table
var savedVisibility = loadColumnVisibility();
Object.keys(savedVisibility).forEach(function(colIndex) {
table.column(parseInt(colIndex)).visible(savedVisibility[colIndex]);
});
// Create column visibility dropdown menu
function createColumnVisibilityMenu() {
var menu = $('#column-visibility-menu');
menu.empty();
columnNames.forEach(function(columnName, index) {
var isVisible = table.column(index).visible();
var checkboxId = 'col-vis-' + index;
var menuItem = $('<li class="column-checkbox"></li>');
var formCheck = $('<div class="form-check"></div>');
var checkbox = $('<input class="form-check-input" type="checkbox" id="' + checkboxId + '"' +
(isVisible ? ' checked' : '') + '>');
var label = $('<label class="form-check-label" for="' + checkboxId + '">' + columnName + '</label>');
checkbox.on('change', function() {
var colIndex = parseInt(this.id.split('-')[2]);
var isChecked = this.checked;
table.column(colIndex).visible(isChecked);
// Update saved visibility
savedVisibility[colIndex] = isChecked;
saveColumnVisibility(savedVisibility);
});
formCheck.append(checkbox, label);
menuItem.append(formCheck);
menu.append(menuItem);
});
}
// Initialize column visibility menu
createColumnVisibilityMenu();
// Prevent dropdown from closing when clicking inside
$('#column-visibility-menu').on('click', function(e) {
e.stopPropagation();
});
// Connect the custom export buttons to DataTables buttons
$('#export-csv').on('click', function() {
table.button('.buttons-csv').trigger();
});
$('#export-excel').on('click', function() {
table.button('.buttons-excel').trigger();
});
$('#print-table').on('click', function() {
table.button('.buttons-print').trigger();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>Welcome to Domain Logon Monitoring</h1>
{% if current_user.is_authenticated %}
<p class="lead">Hello {{ current_user.username }}, welcome back!</p>
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Quick Links:</h5>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a href="{{ url_for('frontend.dashboard') }}" class="text-decoration-none">View Dashboard</a>
</li>
<li class="list-group-item">
<a href="{{ url_for('frontend.time_spent_report') }}" class="text-decoration-none">Time Spent Report</a>
</li>
{% if current_user.role == 'Admin' or current_user.role == 'GlobalAdmin' %}
<br></br><h5 class="card-title">Management:</h5>
<li class="list-group-item">
<a href="{{ url_for('auth.manage_users') }}" class="text-decoration-none">Manage Users</a>
</li>
{% endif %}
{% if current_user.role == 'GlobalAdmin' %}
<li class="list-group-item">
<a href="{{ url_for('auth.manage_companies') }}" class="text-decoration-none">Manage Companies</a>
</li>
<li class="list-group-item">
<a href="{{ url_for('auth.admin_settings') }}" class="text-decoration-none">Site Settings</a>
</li>
<li class="list-group-item">
<a href="{{ url_for('auth.view_error_logs') }}" class="text-decoration-none">View Error Logs</a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% else %}
<p class="lead">Please login {% if allow_registration %}or register {% endif %}to access the monitoring system.</p>
<div class="row mt-4">
<div class="col-md-6">
<a href="{{ url_for('auth.login') }}" class="btn btn-primary mr-2">Login</a>
{% if allow_registration %}
<a href="{{ url_for('auth.register') }}" class="btn btn-secondary">Register</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,302 @@
{% extends "base.html" %}
{% block head %}
<!-- DataTables CSS -->
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
<!-- DateRangePicker CSS -->
<link href="{{ url_for('static', filename='css/daterangepicker.css') }}" rel="stylesheet">
<style>
.btn-group-toggle .btn {
margin-right: 5px;
}
.dataTables_wrapper .dt-buttons {
margin-bottom: 15px;
}
.dt-button {
margin-right: 5px;
}
/* Custom DateRangePicker dark theme styles */
.daterangepicker {
background-color: #212529;
border-color: #495057;
color: #f8f9fa;
}
.daterangepicker .calendar-table {
background-color: #343a40;
border-color: #495057;
}
.daterangepicker td.available:hover,
.daterangepicker th.available:hover {
background-color: #495057;
}
.daterangepicker td.active,
.daterangepicker td.active:hover {
background-color: #0d6efd;
color: #fff;
}
/* In-between dates in the selected range - lighter blue */
.daterangepicker td.in-range {
background-color: #82b1ff; /* Lighter blue */
color: #212529; /* Darker text for better contrast */
}
.daterangepicker td.in-range:hover {
background-color: #75a7f7; /* Slightly darker when hovering */
color: #212529;
}
.daterangepicker .calendar-table .next span,
.daterangepicker .calendar-table .prev span {
border-color: #f8f9fa;
}
.daterangepicker .ranges li:hover,
.daterangepicker .ranges li.active {
background-color: #0d6efd;
color: #fff;
}
.daterangepicker .ranges li {
color: #f8f9fa;
}
.daterangepicker:after {
border-bottom-color: #212529;
}
.daterangepicker:before {
border-bottom-color: #495057;
}
/* Calendar header and weekday styling */
.daterangepicker .calendar-table th {
color: #f8f9fa;
}
/* Month name */
.daterangepicker .month {
color: #f8f9fa;
}
/* Off days (not in current month) */
.daterangepicker td.off {
color: #6c757d;
}
/* Input boxes */
.daterangepicker input.input-mini {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
/* Time picker */
.daterangepicker .calendar-time select {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
/* Apply and cancel buttons */
.daterangepicker .drp-buttons {
border-top-color: #495057;
}
.daterangepicker .drp-buttons .btn {
color: #f8f9fa;
}
/* Input fields focus */
.daterangepicker input.input-mini:focus {
border-color: #0d6efd;
}
/* Time inputs container */
.daterangepicker .calendar-time {
background-color: #343a40;
border-color: #495057;
}
/* Make the export buttons more visible */
.dt-buttons {
margin-top: 10px;
margin-bottom: 15px;
display: block !important;
}
.dt-button {
margin-right: 5px;
}
/* Align the "Show entries" and search box in the same row */
div.dataTables_wrapper div.dataTables_length {
float: left;
padding-top: 0.5em;
}
div.dataTables_wrapper div.dataTables_filter {
float: right;
}
div.dataTables_wrapper div.dataTables_info {
clear: both;
}
</style>
{% endblock %}
{% block title %}Time Spent Report{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-between align-items-center">
<h2>Time Spent Report</h2>
<div class="export-buttons d-flex align-items-center">
<!-- Process span option moved here -->
<div class="form-check me-3">
<input type="checkbox" class="form-check-input" name="continue_iterate" id="continue_iterate" form="reportFilterForm" {% if continue_iterate %}checked{% endif %}>
<label class="form-check-label" for="continue_iterate" data-bs-toggle="tooltip" data-bs-placement="top"
title="When enabled, the system will continue processing time spans across midnight for multi-day sessions. When disabled, each day's activity is calculated separately.">
Process multi-day sessions
</label>
</div>
<!-- Export buttons will be placed here by DataTables -->
</div>
</div>
<div class="card mt-3 mb-3">
<div class="card-body">
<form id="reportFilterForm" method="GET" action="{{ url_for('frontend.time_spent_report') }}">
<div class="row align-items-end">
<div class="col-md-3">
<label for="daterange" class="form-label">Date Range:</label>
<input type="text" id="daterange" name="daterange" class="form-control"
value="{{ start_date.strftime('%Y-%m-%d %H:%M') if start_date else '' }} - {{ end_date.strftime('%Y-%m-%d %H:%M') if end_date else '' }}"/>
</div>
{% if companies %}
<div class="col-md-2">
<label for="company_id" class="form-label">Company:</label>
<select class="form-select" id="company_id" name="company_id">
<option value="">All Companies</option>
{% for company in companies %}
<option value="{{ company.id }}" {% if selected_company_id == company.id %}selected{% endif %}>
{{ company.name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-3">
<label for="group_by" class="form-label">Group By:</label>
<div class="btn-group btn-group-toggle" data-toggle="buttons">
<input type="radio" class="btn-check" name="group_by" id="option1" value="user" {% if group_by == 'user' or not group_by %}checked{% endif %}>
<label class="btn btn-outline-primary btn-sm" for="option1">User</label>
<input type="radio" class="btn-check" name="group_by" id="option2" value="user_computer" {% if group_by == 'user_computer' %}checked{% endif %}>
<label class="btn btn-outline-primary btn-sm" for="option2">User + Computer</label>
</div>
</div>
<div class="col-md-2 mt-3">
<button type="submit" class="btn btn-primary">Apply Filter</button>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<table id="timeSpentTable" class="table table-striped table-bordered" style="width:100%">
<thead>
<tr>
<th>Date</th>
<th>User Name</th>
{% if group_by == 'user_computer' %}
<th>Computer Name</th>
{% endif %}
<th>Company</th>
<th>Total Time</th>
<th>First Login</th>
<th>Last Logout</th>
</tr>
</thead>
<tbody>
{% for entry in time_data %}
<tr>
<td>{{ entry.date }}</td>
<td>{{ entry.user_name }}</td>
{% if group_by == 'user_computer' %}
<td>{{ entry.computer_name }}</td>
{% endif %}
<td>{{ entry.company_name }}</td>
<td data-order="{{ entry.total_seconds }}">{{ entry.formatted_time }}</td>
<td data-order="{{ entry.first_login.strftime('%Y%m%d%H%M%S') if entry.first_login else '' }}">
{{ entry.first_login|format_datetime if entry.first_login else 'N/A' }}
</td>
<td data-order="{{ entry.last_logout.strftime('%Y%m%d%H%M%S') if entry.last_logout else '' }}">
{{ entry.last_logout|format_datetime if entry.last_logout else 'N/A' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- DataTables JS -->
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap5.min.js') }}"></script>
<!-- DataTables Buttons JS -->
<script src="{{ url_for('static', filename='js/dataTables.buttons.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.bootstrap5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.html5.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/buttons.print.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/pdfmake.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/vfs_fonts.js') }}"></script>
<!-- Moment.js -->
<script src="{{ url_for('static', filename='js/moment.min.js') }}"></script>
<!-- DateRangePicker -->
<script src="{{ url_for('static', filename='js/daterangepicker.min.js') }}"></script>
<script>
$(document).ready(function() {
// Initialize date range picker
$('#daterange').daterangepicker({
timePicker: true,
timePicker24Hour: true,
timePickerSeconds: false,
startDate: moment().subtract(7, 'days'),
endDate: moment(),
locale: {
format: 'YYYY-MM-DD HH:mm'
}
});
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// Initialize DataTable with export buttons
var table = $('#timeSpentTable').DataTable({
dom: 'lfrBtip',
buttons: [
{
extend: 'csv',
text: 'Export CSV',
className: 'btn btn-primary btn-sm',
exportOptions: {
columns: ':visible'
}
},
{
extend: 'excel',
text: 'Export Excel',
className: 'btn btn-success btn-sm',
exportOptions: {
columns: ':visible'
}
},
{
extend: 'print',
text: 'Print',
className: 'btn btn-info btn-sm',
exportOptions: {
columns: ':visible'
}
}
],
order: [[0, 'desc']],
pageLength: 25
});
// Move export buttons to the header
table.buttons().container().appendTo('.export-buttons');
});
</script>
{% endblock %}

23
utils/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Utils package for the Domain Logons application.
This package contains utility modules for database logging, timezone handling,
rate limiting, security headers, and health checks.
"""
# Import commonly used functions for easy access
from .toolbox import (
get_app_timezone,
get_current_timestamp,
get_utc_timestamp,
convert_to_app_timezone,
format_timestamp_for_display
)
__all__ = [
'get_app_timezone',
'get_current_timestamp',
'get_utc_timestamp',
'convert_to_app_timezone',
'format_timestamp_for_display'
]

267
utils/db_logging.py Normal file
View File

@@ -0,0 +1,267 @@
import logging
import traceback
import uuid
from datetime import datetime, timezone
from flask import request, g, has_request_context
from extensions import db
from api.models import ErrorLog
import threading
import pytz
from .toolbox import get_app_timezone, get_current_timestamp
class DatabaseLogHandler(logging.Handler):
"""
Custom logging handler that stores log records in the database.
Configurable logging level via database settings.
"""
def __init__(self):
super().__init__()
self.setLevel(logging.WARNING) # Default level, will be updated from config
self._app = None # Store app reference
self._processed_records = set() # Track processed records to avoid duplicates
self._max_cache_size = 1000 # Limit cache size to prevent memory issues
def emit(self, record):
"""
Emit a log record to the database.
This runs in a separate thread to avoid blocking the main application.
"""
# Import filter functions
from .toolbox import get_filtered_loggers, get_filtered_message_patterns
# Get configured filters
filtered_loggers = get_filtered_loggers()
filtered_patterns = get_filtered_message_patterns()
# Skip database logging for filtered loggers to prevent feedback loops
if record.name in filtered_loggers:
return
# Also filter out messages containing specific patterns
message = record.getMessage().lower()
for pattern in filtered_patterns:
if pattern.lower() in message:
return
# Create a unique identifier for this record to prevent duplicates
record_id = (
record.name,
record.levelname,
record.getMessage(),
record.created,
getattr(record, 'pathname', ''),
getattr(record, 'lineno', 0)
)
# Check if we've already processed this exact record
if record_id in self._processed_records:
return
# Add to processed records cache
self._processed_records.add(record_id)
# Clean cache if it gets too large
if len(self._processed_records) > self._max_cache_size:
# Remove oldest half of entries (simple cleanup)
self._processed_records = set(list(self._processed_records)[self._max_cache_size//2:])
# Store the app reference if we have an application context
if not self._app:
try:
from flask import current_app
self._app = current_app._get_current_object()
except RuntimeError:
# No application context available, try to import app
try:
from app import app
self._app = app
except ImportError:
pass
# Use a thread to avoid blocking the main application
threading.Thread(target=self._emit_to_db, args=(record,), daemon=True).start()
def _emit_to_db(self, record):
"""
Actually write the log record to the database.
This method runs in a separate thread.
"""
try:
# Use the stored app reference or try to get it
app = self._app
if not app:
try:
from flask import current_app
app = current_app._get_current_object()
except RuntimeError:
# No application context, try to import app
try:
from app import app
except ImportError:
print("Could not import app for database logging")
return
with app.app_context():
# Extract request information if available
request_id = None
user_id = None
remote_addr = None
if has_request_context():
try:
request_id = getattr(g, 'request_id', str(uuid.uuid4())[:8])
user_id = getattr(g, 'user_id', None)
remote_addr = request.remote_addr
except Exception:
# If we can't get request context, continue without it
pass
# Format exception info if present
exception_text = None
if record.exc_info:
exception_text = ''.join(traceback.format_exception(*record.exc_info))
# Create error log entry
error_log = ErrorLog(
level=record.levelname,
logger_name=record.name,
message=self.format(record),
timestamp=get_current_timestamp(),
pathname=record.pathname if hasattr(record, 'pathname') else None,
lineno=record.lineno if hasattr(record, 'lineno') else None,
request_id=request_id,
user_id=user_id,
remote_addr=remote_addr,
exception=exception_text
)
db.session.add(error_log)
db.session.commit()
except Exception as e:
# If database logging fails, fall back to console logging
# Don't raise the exception to avoid breaking the application
print(f"Failed to log to database: {e}")
def setup_database_logging(app):
"""
Set up database logging for the Flask application.
"""
# Create and configure the database handler
db_handler = DatabaseLogHandler()
db_handler._app = app # Store app reference
# Set initial logging level from settings (will be updated dynamically)
update_logging_level(app, db_handler)
# Set a formatter for the database logs
formatter = logging.Formatter(
'%(name)s - %(levelname)s - %(message)s'
)
db_handler.setFormatter(formatter)
# Store handler reference in app for dynamic updates
app.db_handler = db_handler
# Only add to root logger to avoid duplicate logging
# (app.logger propagates to root logger by default)
root_logger = logging.getLogger()
# Check if we already have this handler to avoid duplicates
handler_exists = False
for handler in root_logger.handlers:
if isinstance(handler, DatabaseLogHandler):
handler_exists = True
break
if not handler_exists:
root_logger.addHandler(db_handler)
# Add request ID to flask request context
@app.before_request
def add_request_id():
g.request_id = str(uuid.uuid4())[:8]
if hasattr(g, 'api_key') and g.api_key and g.api_key.user_id:
g.user_id = g.api_key.user_id
elif hasattr(g, 'current_user') and g.current_user and hasattr(g.current_user, 'id'):
g.user_id = g.current_user.id
app.logger.info("Database logging initialized")
def update_logging_level(app, db_handler=None):
"""
Update the database logging level from settings.
"""
try:
with app.app_context():
from auth.models import Settings
settings = Settings.query.first()
if settings and hasattr(settings, 'log_level'):
# Convert string to logging level
level_map = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL
}
new_level = level_map.get(settings.log_level, logging.WARNING)
# Update the handler if provided
if db_handler:
db_handler.setLevel(new_level)
# Or get it from app if stored
elif hasattr(app, 'db_handler'):
app.db_handler.setLevel(new_level)
app.logger.info(f"Database logging level updated to: {settings.log_level}")
else:
# Default to WARNING if no setting found
if db_handler:
db_handler.setLevel(logging.WARNING)
elif hasattr(app, 'db_handler'):
app.db_handler.setLevel(logging.WARNING)
except Exception as e:
print(f"Failed to update logging level: {e}")
def get_available_log_levels():
"""
Get list of available logging levels for admin configuration.
"""
return [
('DEBUG', 'Debug - All messages'),
('INFO', 'Info - General information and above'),
('WARNING', 'Warning - Warnings and above (default)'),
('ERROR', 'Error - Errors and above'),
('CRITICAL', 'Critical - Only critical errors')
]
def log_error(message, exception=None, level=logging.ERROR):
"""
Convenience function to log errors to the database.
Args:
message: Error message
exception: Exception object (optional)
level: Logging level (default: ERROR)
"""
logger = logging.getLogger(__name__)
if exception:
logger.log(level, message, exc_info=True)
else:
logger.log(level, message)
def log_warning(message):
"""Convenience function to log warnings."""
log_error(message, level=logging.WARNING)
def log_critical(message, exception=None):
"""Convenience function to log critical errors."""
log_error(message, exception, level=logging.CRITICAL)

0
utils/health_check.py Normal file
View File

228
utils/rate_limiter.py Normal file
View File

@@ -0,0 +1,228 @@
import time
import logging
from functools import wraps
from flask import request, jsonify, g
import redis
from werkzeug.exceptions import TooManyRequests
import os
logger = logging.getLogger(__name__)
# Configure Redis connection (if available)
def configure_redis(config=None):
"""Configure Redis connection from config"""
global redis_client
redis_url = None
if config and config.has_option('rate_limiting', 'REDIS_URL'):
redis_url = config.get('rate_limiting', 'REDIS_URL')
if not redis_url:
redis_url = os.environ.get('REDIS_URL', None)
if redis_url:
try:
redis_client = redis.from_url(redis_url)
redis_client.ping() # Test connection
logger.info("Redis connected for rate limiting")
except Exception as e:
logger.warning(f"Redis connection failed for rate limiting: {str(e)}")
redis_client = None
else:
logger.info("No Redis URL configured, using in-memory rate limiting")
# Initialize Redis to None
redis_client = None
# In-memory rate limit storage (fallback if Redis is not available)
rate_limit_storage = {}
def rate_limit(limit=60, per=60, scope_func=None):
"""
Rate limiting decorator for routes.
Args:
limit (int): Maximum number of requests allowed in the time period
per (int): Time period in seconds
scope_func (callable): Function to determine rate limit scope (default: by IP)
Example usage:
@app.route('/api/endpoint')
@rate_limit(limit=10, per=60) # 10 requests per minute
def api_endpoint():
return jsonify({"status": "success"})
"""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
# Get scope key (default to IP address)
if scope_func:
scope = scope_func()
else:
# Default to client IP
scope = get_remote_address()
# Create a unique key for this route and scope
key = f"rate_limit:{request.path}:{scope}"
# Check rate limit
current = get_rate_limit_value(key)
# If this is a new key or expired, initialize it
if current is None:
set_rate_limit_value(key, 1, per)
current = 1
else:
# Increment counter
current = increment_rate_limit_value(key)
# Add rate limit headers
g.rate_limit_headers = {
'X-RateLimit-Limit': str(limit),
'X-RateLimit-Remaining': str(max(0, limit - current)),
'X-RateLimit-Reset': str(int(time.time() + get_ttl(key)))
}
# If over limit, return 429 Too Many Requests
if current > limit:
logger.warning(f"Rate limit exceeded for {scope} on {request.path}")
response = jsonify({
'error': 'Too many requests',
'message': f'Rate limit of {limit} requests per {per} seconds exceeded'
})
response.status_code = 429
# Add rate limit headers to response
for key, value in g.rate_limit_headers.items():
response.headers[key] = value
return response
# Execute the original route function
response = f(*args, **kwargs)
# Convert string response to Response object if needed
from flask import make_response
if isinstance(response, str):
response = make_response(response)
# Add rate limit headers to response
if hasattr(response, 'headers'):
for key, value in g.rate_limit_headers.items():
response.headers[key] = value
return response
return wrapped
return decorator
def get_remote_address():
"""Get the client's IP address, respecting proxy headers"""
# Check X-Forwarded-For header (used by Traefik and other proxies)
if request.headers.get('X-Forwarded-For'):
# Get the first IP in the chain (original client IP)
forwarded_for = request.headers.get('X-Forwarded-For').split(',')[0].strip()
logger.debug(f"Using X-Forwarded-For IP: {forwarded_for} (from: {request.headers.get('X-Forwarded-For')})")
return forwarded_for
# Check X-Real-IP header (alternative proxy header)
if request.headers.get('X-Real-IP'):
real_ip = request.headers.get('X-Real-IP').strip()
logger.debug(f"Using X-Real-IP: {real_ip}")
return real_ip
# Fallback to direct connection IP
remote_addr = request.remote_addr
logger.debug(f"Using direct remote_addr: {remote_addr}")
return remote_addr
# Redis implementations
def get_rate_limit_value(key):
"""Get current rate limit counter value"""
if redis_client:
value = redis_client.get(key)
return int(value) if value else None
else:
# Fallback to in-memory storage
if key in rate_limit_storage:
# Check if expired
if time.time() > rate_limit_storage[key]['expires']:
del rate_limit_storage[key]
return None
return rate_limit_storage[key]['value']
return None
def set_rate_limit_value(key, value, ttl):
"""Set rate limit counter with TTL"""
if redis_client:
redis_client.setex(key, ttl, value)
else:
# Fallback to in-memory storage
rate_limit_storage[key] = {
'value': value,
'expires': time.time() + ttl
}
def increment_rate_limit_value(key):
"""Increment rate limit counter"""
if redis_client:
return redis_client.incr(key)
else:
# Fallback to in-memory storage
if key in rate_limit_storage:
rate_limit_storage[key]['value'] += 1
return rate_limit_storage[key]['value']
return 1
def get_ttl(key):
"""Get remaining TTL for a key"""
if redis_client:
ttl = redis_client.ttl(key)
return max(0, ttl)
else:
# Fallback to in-memory storage
if key in rate_limit_storage:
return max(0, rate_limit_storage[key]['expires'] - time.time())
return 0
# Utility to apply rate limits to entire blueprints
def apply_rate_limits(app, config=None):
"""Apply rate limits to sensitive routes"""
if not config:
return
# Check if rate limiting is enabled
if not config.getboolean('rate_limiting', 'ENABLE_RATE_LIMITING', fallback=True):
return
# Configure Redis connection
configure_redis(config)
# Get rate limiting configuration
login_limit = config.getint('rate_limiting', 'LOGIN_LIMIT', fallback=10)
login_period = config.getint('rate_limiting', 'LOGIN_PERIOD', fallback=60)
register_limit = config.getint('rate_limiting', 'REGISTER_LIMIT', fallback=5)
register_period = config.getint('rate_limiting', 'REGISTER_PERIOD', fallback=300)
api_limit = config.getint('rate_limiting', 'API_LIMIT', fallback=60)
api_period = config.getint('rate_limiting', 'API_PERIOD', fallback=60)
# Login endpoint
if 'auth.login' in app.view_functions:
app.view_functions['auth.login'] = rate_limit(
limit=login_limit, per=login_period
)(app.view_functions['auth.login'])
# Register endpoint
if 'auth.register' in app.view_functions:
app.view_functions['auth.register'] = rate_limit(
limit=register_limit, per=register_period
)(app.view_functions['auth.register'])
# API endpoints
api_routes = [route for route in app.url_map.iter_rules()
if route.rule.startswith('/api/')]
for route in api_routes:
if route.endpoint in app.view_functions:
app.view_functions[route.endpoint] = rate_limit(
limit=api_limit, per=api_period
)(app.view_functions[route.endpoint])

67
utils/security_headers.py Normal file
View File

@@ -0,0 +1,67 @@
import os
import logging
from flask import request, current_app
logger = logging.getLogger(__name__)
def add_security_headers(response):
"""
Add security headers to HTTP responses
Headers added:
- Content-Security-Policy: Prevents XSS attacks by specifying content sources
- X-Content-Type-Options: Prevents MIME type sniffing
- X-Frame-Options: Prevents clickjacking
- X-XSS-Protection: Additional XSS protection for older browsers
- Referrer-Policy: Controls referrer information
- Strict-Transport-Security: Enforces HTTPS
- Permissions-Policy: Controls browser features
"""
# Content Security Policy - restricts sources of content
csp = current_app.config.get('CONTENT_SECURITY_POLICY',
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'self'; "
"form-action 'self'; "
"base-uri 'self'"
)
response.headers['Content-Security-Policy'] = csp
# Prevent MIME type sniffing
response.headers['X-Content-Type-Options'] = 'nosniff'
# Prevent clickjacking
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
# Additional XSS protection for older browsers
response.headers['X-XSS-Protection'] = '1; mode=block'
# Control referrer information
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# HSTS - force HTTPS (only in production)
if not current_app.debug and not current_app.testing:
hsts_enabled = current_app.config.get('HSTS_ENABLED', True)
if hsts_enabled:
hsts_max_age = current_app.config.get('HSTS_MAX_AGE', 31536000)
response.headers['Strict-Transport-Security'] = f'max-age={hsts_max_age}; includeSubDomains'
# Permissions Policy (formerly Feature-Policy)
response.headers['Permissions-Policy'] = (
'camera=(), microphone=(), geolocation=(), interest-cohort=()'
)
return response
def setup_security_headers(app, config=None):
"""Register security headers middleware with Flask app"""
if config and config.getboolean('security', 'ENABLE_SECURITY_HEADERS', fallback=True):
# Set CSP from config if available
if config.has_option('security', 'CONTENT_SECURITY_POLICY'):
app.config['CONTENT_SECURITY_POLICY'] = config.get('security', 'CONTENT_SECURITY_POLICY')
app.after_request(add_security_headers)

179
utils/toolbox.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Timezone utility functions for the Domain Logons application.
This module provides timezone-related utilities that can be used throughout
the application without causing circular import issues.
"""
from datetime import datetime, timezone
import pytz
def get_app_timezone():
"""Get the application timezone from config."""
try:
# Try to get app from Flask context first
from flask import current_app
app = current_app._get_current_object()
if app and hasattr(app, 'config'):
tz_name = app.config.get('TIMEZONE', 'Europe/London')
return pytz.timezone(tz_name)
except RuntimeError:
# No application context, try to get config from os.environ or config file
try:
import configparser
import os
config = configparser.ConfigParser()
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
if os.path.exists(config_path):
config.read(config_path)
tz_name = config.get('app', 'TIMEZONE', fallback='Europe/London')
return pytz.timezone(tz_name)
except Exception:
pass
except Exception:
pass
# Fallback to UTC
return pytz.UTC
def get_current_timestamp():
"""Get current timestamp in the application's configured timezone."""
app_tz = get_app_timezone()
# Use timezone.utc instead of deprecated utcnow()
utc_now = datetime.now(timezone.utc)
# Convert to application timezone
return utc_now.astimezone(app_tz).replace(tzinfo=None) # Store as naive datetime in app timezone
def get_utc_timestamp():
"""Get current UTC timestamp as timezone-aware datetime."""
return datetime.now(timezone.utc)
def convert_to_app_timezone(dt):
"""
Convert a datetime to the application's configured timezone.
Args:
dt: datetime object (can be naive or timezone-aware)
Returns:
datetime: timezone-aware datetime in application timezone
"""
app_tz = get_app_timezone()
if dt.tzinfo is None:
# If naive, assume it's already in the application timezone
return app_tz.localize(dt)
else:
# If timezone-aware, convert to application timezone
return dt.astimezone(app_tz)
def format_timestamp_for_display(dt):
"""
Format a datetime for display with timezone information.
Args:
dt: datetime object
Returns:
str: formatted timestamp string
"""
if dt is None:
return ""
# Convert to app timezone if needed
if dt.tzinfo is None:
# Assume naive datetime is already in app timezone
app_tz = get_app_timezone()
localized_dt = app_tz.localize(dt)
else:
# Convert timezone-aware datetime to app timezone
localized_dt = convert_to_app_timezone(dt)
return localized_dt.strftime('%Y-%m-%d %H:%M:%S %Z')
def get_filtered_loggers():
"""
Get list of logger names that should be filtered out of database logging.
This helps prevent feedback loops and reduces noise.
"""
default_filters = []
try:
# Try to get app from Flask context first
from flask import current_app
app = current_app._get_current_object()
if app and hasattr(app, 'config'):
# Get filters from app config (which loads from config.ini)
filter_string = app.config.get('DB_LOGGING_FILTERED_LOGGERS', '')
if filter_string:
return [logger.strip() for logger in filter_string.split(',') if logger.strip()]
except RuntimeError:
# No application context, try to read config directly
try:
import configparser
import os
config = configparser.ConfigParser()
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
if os.path.exists(config_path):
config.read(config_path)
filter_string = config.get('logging', 'DB_LOGGING_FILTERED_LOGGERS', fallback='')
if filter_string:
return [logger.strip() for logger in filter_string.split(',') if logger.strip()]
except Exception:
pass
except Exception:
pass
# Fallback defaults if config not available
return [
'watchfiles.main',
'watchfiles.watcher',
'watchdog',
'uvicorn.access'
]
def get_filtered_message_patterns():
"""
Get list of message patterns that should be filtered out of database logging.
"""
default_patterns = []
try:
# Try to get app from Flask context first
from flask import current_app
app = current_app._get_current_object()
if app and hasattr(app, 'config'):
# Get patterns from app config (which loads from config.ini)
pattern_string = app.config.get('DB_LOGGING_FILTERED_PATTERNS', '')
if pattern_string:
return [pattern.strip() for pattern in pattern_string.split(',') if pattern.strip()]
except RuntimeError:
# No application context, try to read config directly
try:
import configparser
import os
config = configparser.ConfigParser()
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
if os.path.exists(config_path):
config.read(config_path)
pattern_string = config.get('logging', 'DB_LOGGING_FILTERED_PATTERNS', fallback='')
if pattern_string:
return [pattern.strip() for pattern in pattern_string.split(',') if pattern.strip()]
except Exception:
pass
except Exception:
pass
# Fallback defaults if config not available
return [
'database.db',
'instance/',
'file changed',
'reloading'
]

Binary file not shown.