| |
| """ |
| Business Automation Suite - AI-Ready Core v3.0 (User Management Added) |
| Designed by Gemini Advanced Development Unit under impeccable direction. |
| Target Platform: Hugging Face Spaces (Free Tier) |
| Architecture: Single-File Ultra-Efficient Application with API Backend |
| """ |
|
|
| import sqlite3 |
| import hashlib |
| import secrets |
| import datetime |
| import json |
| import os |
| from flask import Flask, request, jsonify, session, render_template_string, redirect, url_for, Response |
| from functools import wraps |
| from typing import Dict, List, Tuple, Optional, Any, Union |
|
|
| |
|
|
| DB_NAME = "business_suite_v3.db" |
| SALT_LENGTH = 16 |
| SECRET_KEY = secrets.token_hex(24) |
|
|
| |
|
|
| def get_db_connection() -> sqlite3.Connection: |
| """Establishes and returns a database connection. Enables Foreign Keys.""" |
| conn = sqlite3.connect(DB_NAME, check_same_thread=False) |
| conn.row_factory = sqlite3.Row |
| conn.execute("PRAGMA foreign_keys = ON;") |
| return conn |
|
|
| def setup_database(): |
| """Initializes the database schema if it doesn't exist. Idempotent.""" |
| required_tables = {"users", "business_config", "inventory_items", "suppliers", "purchase_suggestions", "event_log"} |
| conn_check = None |
| try: |
| db_exists = os.path.exists(DB_NAME) |
| if db_exists: |
| conn_check = get_db_connection() |
| cursor_check = conn_check.cursor() |
| cursor_check.execute("SELECT name FROM sqlite_master WHERE type='table';") |
| existing_tables = {row[0] for row in cursor_check.fetchall()} |
| conn_check.close() |
| else: |
| existing_tables = set() |
|
|
| if not required_tables.issubset(existing_tables): |
| print("INFO: Database schema not found or incomplete. Initializing...") |
| conn = get_db_connection() |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS users ( |
| username TEXT PRIMARY KEY, |
| hashed_password TEXT NOT NULL, |
| salt TEXT NOT NULL, |
| is_admin INTEGER DEFAULT 0 NOT NULL, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ); |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS business_config ( |
| config_key TEXT PRIMARY KEY, |
| config_value TEXT, |
| description TEXT, |
| last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ); |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS inventory_items ( |
| item_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| item_name TEXT UNIQUE NOT NULL, |
| description TEXT, |
| current_quantity REAL DEFAULT 0 NOT NULL, |
| unit TEXT DEFAULT 'units' NOT NULL, |
| reorder_level REAL, |
| preferred_supplier_id INTEGER, |
| last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| FOREIGN KEY (preferred_supplier_id) REFERENCES suppliers(supplier_id) ON DELETE SET NULL |
| ); |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS suppliers ( |
| supplier_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| supplier_name TEXT UNIQUE NOT NULL, |
| contact_info TEXT, |
| notes TEXT, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ); |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS purchase_suggestions ( |
| suggestion_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| item_id INTEGER NOT NULL, |
| suggested_quantity REAL NOT NULL, |
| supplier_id INTEGER, |
| reason TEXT, |
| status TEXT DEFAULT 'pending' NOT NULL CHECK(status IN ('pending', 'ordered', 'received', 'cancelled')), |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| resolved_at TIMESTAMP, |
| FOREIGN KEY (item_id) REFERENCES inventory_items(item_id) ON DELETE CASCADE, |
| FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id) ON DELETE SET NULL |
| ); |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS event_log ( |
| log_id INTEGER PRIMARY KEY AUTOINCREMENT, |
| event_type TEXT NOT NULL, |
| username TEXT, |
| details TEXT, |
| timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| ); |
| """) |
|
|
| |
| |
| cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1;") |
| if cursor.fetchone()[0] == 0: |
| print("INFO: Creating default admin user 'admin' with password 'changeme'. PLEASE CHANGE THIS PASSWORD.") |
| default_username = "admin" |
| default_password = "changeme" |
| salt = secrets.token_hex(SALT_LENGTH) |
| hashed_password = hash_password(default_password, salt) |
| cursor.execute(""" |
| INSERT INTO users (username, hashed_password, salt, is_admin) |
| VALUES (?, ?, ?, 1) |
| """, (default_username, hashed_password, salt)) |
| |
| cursor.execute("INSERT INTO event_log (username, event_type, details) VALUES (?, ?, ?)", |
| ('System', 'DEFAULT_ADMIN_CREATED', json.dumps({'username': default_username}))) |
|
|
|
|
| |
| default_config = { |
| 'business_name': ('My Automated Business', 'The name displayed in the UI'), |
| 'low_stock_alert_threshold_pct': ('0.1', 'Alert if stock falls below 10% of reorder level (if set)'), |
| 'default_currency': ('USD', 'Default currency symbol') |
| } |
| for key, (value, desc) in default_config.items(): |
| cursor.execute("INSERT OR IGNORE INTO business_config (config_key, config_value, description) VALUES (?, ?, ?)", (key, value, desc)) |
|
|
| conn.commit() |
| print("INFO: Database initialization complete.") |
| conn.close() |
| else: |
| print("INFO: Database schema already exists.") |
|
|
| except sqlite3.Error as e: |
| print(f"FATAL: Database setup/check failed: {e}") |
| if conn_check: conn_check.close() |
| raise |
| except Exception as e: |
| print(f"FATAL: An unexpected error occurred during database setup: {e}") |
| if conn_check: conn_check.close() |
| raise |
|
|
| |
|
|
| def hash_password(password: str, salt: str) -> str: |
| """Hashes the password with the given salt using SHA-256.""" |
| password_bytes = password.encode('utf-8') |
| salt_bytes = salt.encode('utf-8') |
| hashed = hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, 100000) |
| return hashed.hex() |
|
|
| def verify_password(stored_password_hash: str, salt: str, provided_password: str) -> bool: |
| """Verifies a provided password against a stored hash and salt.""" |
| provided_hash = hash_password(provided_password, salt) |
| return provided_hash == stored_password_hash |
|
|
| |
|
|
| def login_required(f): |
| """Decorator to restrict access to logged-in users.""" |
| @wraps(f) |
| def decorated_function(*args, **kwargs): |
| if 'username' not in session: |
| |
| if request.endpoint and request.endpoint.startswith('api_'): |
| return jsonify({"success": False, "message": "Authentication required"}), 401 |
| |
| return redirect(url_for('index')) |
| return f(*args, **kwargs) |
| return decorated_function |
|
|
| def admin_required(f): |
| """Decorator to restrict access to admin users.""" |
| @wraps(f) |
| @login_required |
| def decorated_function(*args, **kwargs): |
| if not session.get('is_admin', False): |
| |
| if request.endpoint and request.endpoint.startswith('api_'): |
| return jsonify({"success": False, "message": "Admin access required"}), 403 |
| |
| return jsonify({"success": False, "message": "Admin access required"}), 403 |
| return f(*args, **kwargs) |
| return decorated_function |
|
|
|
|
| |
|
|
| def log_event(username: Optional[str], event_type: str, details: Dict[str, Any]): |
| """Logs an event to the database.""" |
| |
| serializable_details = {} |
| for k, v in details.items(): |
| if isinstance(v, (str, int, float, bool, list, dict, tuple)) or v is None: |
| serializable_details[k] = v |
| else: |
| try: |
| serializable_details[k] = json.dumps(v) |
| except TypeError: |
| serializable_details[k] = str(v) |
|
|
|
|
| try: |
| conn = get_db_connection() |
| cursor = conn.cursor() |
| |
| event_details = serializable_details |
| if request and request.remote_addr: |
| event_details['ip_address'] = request.remote_addr |
|
|
|
|
| cursor.execute(""" |
| INSERT INTO event_log (username, event_type, details) |
| VALUES (?, ?, ?) |
| """, (username, event_type, json.dumps(event_details))) |
| conn.commit() |
| conn.close() |
| except sqlite3.Error as e: |
| print(f"ERROR: Failed to log event {event_type} for user {username}: {e}") |
| except TypeError as te: |
| print(f"ERROR: Failed to serialize details for event {event_type}: {te} - Details: {serializable_details}") |
| except Exception as ex: |
| print(f"ERROR: Unexpected error in log_event for {event_type}: {ex}") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| def get_users_logic() -> List[Dict[str, Any]]: |
| """Retrieves a list of all users (excluding sensitive info).""" |
| users = [] |
| try: |
| conn = get_db_connection() |
| cursor = conn.cursor() |
| |
| cursor.execute("SELECT username, is_admin, created_at FROM users ORDER BY username;") |
| users = [dict(row) for row in cursor.fetchall()] |
| conn.close() |
| except sqlite3.Error as e: |
| print(f"ERROR: Failed to retrieve users: {e}") |
| |
| return users |
|
|
|
|
| def add_user_logic(username: str, password: str, is_admin: bool, creating_user_username: str) -> Tuple[bool, str]: |
| """Adds a new user to the database.""" |
| if not username or not password: |
| return False, "Username and password are required." |
|
|
| try: |
| |
| username = username.strip() |
| if len(username) < 3: return False, "Username must be at least 3 characters long." |
| if len(password) < 6: return False, "Password must be at least 6 characters long." |
|
|
| salt = secrets.token_hex(SALT_LENGTH) |
| hashed_password = hash_password(password, salt) |
|
|
| conn = get_db_connection() |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute("SELECT COUNT(*) FROM users WHERE username = ?", (username,)) |
| if cursor.fetchone()[0] > 0: |
| conn.close() |
| log_event(creating_user_username, 'ADD_USER_FAILURE', {'reason': 'Username exists', 'new_username': username}) |
| return False, f"Username '{username}' already exists." |
|
|
| |
| cursor.execute(""" |
| INSERT INTO users (username, hashed_password, salt, is_admin) |
| VALUES (?, ?, ?, ?) |
| """, (username, hashed_password, salt, 1 if is_admin else 0)) |
|
|
| conn.commit() |
| conn.close() |
| log_event(creating_user_username, 'USER_ADDED', {'new_username': username, 'is_admin': is_admin}) |
| return True, f"User '{username}' added successfully." |
|
|
| except sqlite3.IntegrityError: |
| |
| log_event(creating_user_username, 'ADD_USER_FAILURE', {'reason': 'Integrity error (duplicate)', 'new_username': username}) |
| return False, f"Username '{username}' already exists (integrity error)." |
| except sqlite3.Error as e: |
| print(f"ERROR: Failed to add user {username}: {e}") |
| log_event(creating_user_username, 'ADD_USER_ERROR', {'new_username': username, 'error': str(e)}) |
| return False, f"Database error adding user: {e}" |
| except Exception as e: |
| print(f"ERROR: Unexpected error in add_user_logic for {username}: {e}") |
| log_event(creating_user_username, 'ADD_USER_UNEXPECTED_ERROR', {'new_username': username, 'error': str(e)}) |
| return False, f"An unexpected server error occurred: {e}" |
|
|
|
|
| |
| app = Flask(__name__) |
| app.secret_key = SECRET_KEY |
|
|
| |
|
|
| |
| |
| HTML_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Business Suite v3.0</title> |
| <style> |
| /* Ultra-minimalist CSS for efficiency - Inherited from v2 */ |
| body { font-family: sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f4f4f4; color: #333; } |
| .container { max-width: 1200px; margin: 20px auto; padding: 20px; background-color: #fff; box-shadow: 0 0 10px rgba(0,0,0,0.1); border-radius: 8px; } |
| header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; } |
| header h1 { margin: 0; color: #555; font-size: 1.8em; } |
| #user-info { font-size: 0.9em; } |
| #logout-btn { background-color: #dc3545; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; font-size: 0.9em; } |
| #logout-btn:hover { background-color: #bb2d3b; } |
| nav button { background-color: #007bff; color: white; border: none; padding: 10px 15px; margin-right: 10px; border-radius: 4px; cursor: pointer; font-size: 1em; } |
| nav button:hover { background-color: #0056b3; } |
| nav button.active { background-color: #0056b3; font-weight: bold; } |
| h2 { color: #007bff; border-bottom: 2px solid #007bff; padding-bottom: 5px; margin-top: 30px; } |
| section { display: none; margin-top: 20px; animation: fadeIn 0.5s; } |
| section.active { display: block; } |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } |
| table { width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 0.9em; } |
| th, td { border: 1px solid #ddd; padding: 10px; text-align: left; } |
| th { background-color: #f0f0f0; font-weight: bold; } |
| tr:nth-child(even) { background-color: #f9f9f9; } |
| form { margin-top: 20px; padding: 15px; background-color: #f9f9f9; border: 1px solid #eee; border-radius: 5px; } |
| form label { display: block; margin-bottom: 5px; font-weight: bold; } |
| form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea, form input[type="checkbox"] { |
| padding: 10px; |
| margin-bottom: 10px; |
| border: 1px solid #ccc; |
| border-radius: 4px; |
| box-sizing: border-box; /* Include padding and border */ |
| display: inline-block; /* Align with labels */ |
| vertical-align: middle; /* Align with labels */ |
| } |
| form input[type="checkbox"] { width: auto; margin-right: 5px; } |
| form label[for] { /* Specific styling for labels linked to inputs */ |
| display: inline-block; /* Allow label and input to be on the same line */ |
| margin-bottom: 10px; |
| margin-right: 15px; /* Space between label/input pairs */ |
| vertical-align: middle; |
| font-weight: normal; /* Reduce weight for inline labels */ |
| } |
| form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea { |
| width: calc(100% - 22px); /* Default for full width */ |
| display: block; /* Revert to block for full-width inputs */ |
| margin-right: 0; |
| } |
| .form-group { margin-bottom: 0; } |
| .form-group label[for] { display: block; font-weight: bold; margin-bottom: 5px; } /* Revert labels in form-group */ |
| |
| form textarea { height: 60px; } |
| form button { background-color: #28a745; color: white; border: none; padding: 12px 20px; border-radius: 4px; cursor: pointer; font-size: 1em; } |
| form button:hover { background-color: #218838; } |
| .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; } |
| .status-message { padding: 10px; margin-top: 15px; border-radius: 4px; font-weight: bold; text-align: center; } |
| .status-success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } |
| .status-error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } |
| .hidden { display: none !important; } /* Use important to override form-grid display */ |
| .accordion { background-color: #eee; color: #444; cursor: pointer; padding: 12px; width: 100%; text-align: left; border: none; outline: none; transition: 0.4s; margin-top: 10px; border-radius: 4px; } |
| .accordion:hover, .accordion.active { background-color: #ccc; } |
| .panel { padding: 0 18px; background-color: white; display: none; overflow: hidden; border: 1px solid #eee; border-top: none; border-radius: 0 0 4px 4px; } |
| /* Login Form Specifics - Inherited from v2 */ |
| #login-form { max-width: 400px; margin: 50px auto; padding: 30px; background-color: #fff; box-shadow: 0 0 15px rgba(0,0,0,0.2); border-radius: 8px; } |
| #login-form h2 { text-align: center; margin-bottom: 25px; color: #333; border: none;} |
| #login-form button { width: 100%; background-color: #007bff; } |
| #login-form button:hover { background-color: #0056b3; } |
| .spinner { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite; display: inline-block; margin-left: 10px; vertical-align: middle; } |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| .loading { text-align: center; padding: 20px; font-style: italic; color: #777; } |
| button .spinner { display: none; } /* Hide spinner by default */ |
| button.loading .spinner { display: inline-block; } /* Show spinner when button has loading class */ |
| button.loading span { vertical-align: middle; } /* Align text with spinner */ |
| |
| /* Specific style for the checkbox label */ |
| #add-user-form .form-group label[for="add-user-is-admin"] { |
| display: inline-block; /* Keep label and checkbox on the same line */ |
| margin-right: 5px; |
| font-weight: normal; |
| vertical-align: middle; |
| width: auto; /* Allow label width to shrink */ |
| } |
| #add-user-form .form-group input[type="checkbox"] { |
| width: auto; /* Ensure checkbox width is default */ |
| margin-right: 15px; |
| vertical-align: middle; |
| } |
| |
| </style> |
| </head> |
| <body> |
| |
| <div id="login-section" class="container {{ 'hidden' if session.get('username') else '' }}"> |
| <form id="login-form"> |
| <h2>Business Suite Login</h2> |
| <div id="login-error" class="status-message status-error hidden"></div> |
| <div class="form-group"> |
| <label for="username">Username:</label> |
| <input type="text" id="username" name="username" required> |
| </div> |
| <div class="form-group"> |
| <label for="password">Password:</label> |
| <input type="password" id="password" name="password" required> |
| </div> |
| <button type="submit"><span>Login</span><div class="spinner"></div></button> |
| </form> |
| </div> |
| |
| <div id="app-section" class="container {{ '' if session.get('username') else 'hidden' }}"> |
| <header> |
| <h1>Business Suite</h1> |
| <div id="user-info"> |
| Logged in as: <strong id="user-display">{{ session.get('username', '') }}</strong> |
| (<span id="admin-display">{{ 'Admin' if session.get('is_admin') else 'User' }}</span>) |
| <button id="logout-btn">Logout</button> |
| </div> |
| </header> |
| |
| <nav> |
| <button data-section="inventory" class="nav-btn active">📦 Inventory</button> |
| <button data-section="suppliers" class="nav-btn">🤝 Suppliers</button> |
| <button data-section="suggestions" class="nav-btn">💡 Suggestions</button> |
| {# NEW: User Management Button - Only visible if is_admin is true #} |
| <button data-section="users" class="nav-btn {{ '' if session.get('is_admin') else 'hidden' }}">👥 Users</button> |
| </nav> |
| |
| <div id="global-status" class="status-message hidden"></div> |
| |
| <!-- Inventory Section - Inherited from v2 --> |
| <section id="inventory-section" class="active"> |
| <h2>Inventory Management</h2> |
| <button class="accordion">Perform Inventory Actions ▼</button> |
| <div class="panel"> |
| <form id="inventory-action-form" class="form-grid"> |
| <div class="form-group"> |
| <label for="inv-action-type">Action Type:</label> |
| <select id="inv-action-type" name="action_type"> |
| <option value="sale">Record Sale (-)</option> |
| <option value="adjustment">Record Adjustment (+/-)</option> |
| <option value="delivery">Record Delivery (+)</option> |
| </select> |
| </div> |
| <div class="form-group"> |
| <label for="inv-item-id">Item ID:</label> |
| <input type="number" id="inv-item-id" name="item_id" required min="1" step="1"> |
| </div> |
| <div class="form-group"> |
| <label for="inv-quantity-change">Quantity:</label> |
| <input type="number" id="inv-quantity-change" name="quantity_change" required step="any"> |
| </div> |
| <div class="form-group" style="grid-column: 1 / -1;"> |
| <label for="inv-reason">Reason/Note:</label> |
| <input type="text" id="inv-reason" name="reason" placeholder="e.g., Customer order #123, Stocktake correction"> |
| </div> |
| <div class="form-group" style="grid-column: 1 / -1;"> |
| <button type="submit"><span>Submit Action</span><div class="spinner"></div></button> |
| </div> |
| </form> |
| </div> |
| |
| <button class="accordion">Add New Inventory Item ▼</button> |
| <div class="panel"> |
| <form id="add-item-form" class="form-grid"> |
| <div class="form-group"> |
| <label for="add-item-name">Name *:</label> |
| <input type="text" id="add-item-name" name="name" required> |
| </div> |
| <div class="form-group"> |
| <label for="add-item-unit">Unit *:</label> |
| <input type="text" id="add-item-unit" name="unit" value="units" required> |
| </div> |
| <div class="form-group"> |
| <label for="add-item-initial-qty">Initial Qty:</label> |
| <input type="number" id="add-item-initial-qty" name="initial_quantity" value="0" step="any"> |
| </div> |
| <div class="form-group"> |
| <label for="add-item-desc">Description:</label> |
| <input type="text" id="add-item-desc" name="description"> |
| </div> |
| <div class="form-group"> |
| <label for="add-item-reorder-lvl">Reorder Lvl:</label> |
| <input type="number" id="add-item-reorder-lvl" name="reorder_level" step="any" placeholder="Optional"> |
| </div> |
| <div class="form-group"> |
| <label for="add-item-supplier-id">Pref. Supplier ID:</label> |
| <input type="number" id="add-item-supplier-id" name="preferred_supplier_id" step="1" placeholder="Optional"> |
| </div> |
| <div class="form-group" style="grid-column: 1 / -1;"> |
| <button type="submit"><span>Add Item</span><div class="spinner"></div></button> |
| </div> |
| </form> |
| </div> |
| |
| <h3>Current Inventory</h3> |
| <button id="refresh-inventory-btn"><span>Refresh List</span><div class="spinner"></div></button> |
| <div id="inventory-table-container"><div class="loading">Loading inventory...</div></div> |
| </section> |
| |
| <!-- Suppliers Section - Inherited from v2 --> |
| <section id="suppliers-section"> |
| <h2>Supplier Management</h2> |
| <button class="accordion">Add New Supplier ▼</button> |
| <div class="panel"> |
| <form id="add-supplier-form" class="form-grid"> |
| <div class="form-group"> |
| <label for="add-supplier-name">Name *:</label> |
| <input type="text" id="add-supplier-name" name="name" required> |
| </div> |
| <div class="form-group"> |
| <label for="add-supplier-contact">Contact Info:</label> |
| <input type="text" id="add-supplier-contact" name="contact_info"> |
| </div> |
| <div class="form-group" style="grid-column: 1 / -1;"> |
| <label for="add-supplier-notes">Notes:</label> |
| <textarea id="add-supplier-notes" name="notes"></textarea> |
| </div> |
| <div class="form-group" style="grid-column: 1 / -1;"> |
| <button type="submit"><span>Add Supplier</span><div class="spinner"></div></button> |
| </div> |
| </form> |
| </div> |
| |
| <h3>Supplier List</h3> |
| <button id="refresh-suppliers-btn"><span>Refresh List</span><div class="spinner"></div></button> |
| <div id="suppliers-table-container"><div class="loading">Loading suppliers...</div></div> |
| </section> |
| |
| <!-- Suggestions Section - Inherited from v2 --> |
| <section id="suggestions-section"> |
| <h2>Purchase Suggestions</h2> |
| <button class="accordion">Resolve Pending Suggestion ▼</button> |
| <div class="panel"> |
| <form id="resolve-suggestion-form" class="form-grid"> |
| <div class="form-group"> |
| <label for="resolve-suggestion-id">Suggestion ID *:</label> |
| <select id="resolve-suggestion-id" name="suggestion_id" required> |
| <option value="">-- Select Suggestion --</option> |
| </select> |
| </div> |
| <div class="form-group"> |
| <label for="resolve-action">Action Taken *:</label> |
| <select id="resolve-action" name="action" required> |
| <option value="ordered">Ordered</option> |
| <option value="received">Received</option> |
| <option value="cancelled">Cancelled</option> |
| </select> |
| </div> |
| <div class="form-group" id="received-qty-group" class="hidden"> {/* Initially hidden */} |
| <label for="resolve-received-qty">Quantity Received *:</label> |
| <input type="number" id="resolve-received-qty" name="received_quantity" step="any" min="0.01"> |
| </div> |
| <div class="form-group" style="grid-column: 1 / -1;"> |
| <button type="submit"><span>Resolve Suggestion</span><div class="spinner"></div></button> |
| </div> |
| </form> |
| </div> |
| |
| <h3>Pending Suggestions</h3> |
| <button id="refresh-suggestions-btn"><span>Refresh List</span><div class="spinner"></div></button> |
| <div id="suggestions-table-container"><div class="loading">Loading suggestions...</div></div> |
| </section> |
| |
| {# NEW: User Management Section #} |
| <section id="users-section"> |
| <h2>User Management</h2> |
| <p>Manage system users. Requires Admin privileges.</p> |
| |
| <button class="accordion">Add New User ▼</button> |
| <div class="panel"> |
| <form id="add-user-form"> {/* Not using form-grid here for simpler layout */} |
| <div class="form-group"> |
| <label for="add-user-username">Username *:</label> |
| <input type="text" id="add-user-username" name="username" required minlength="3"> |
| </div> |
| <div class="form-group"> |
| <label for="add-user-password">Password *:</label> |
| <input type="password" id="add-user-password" name="password" required minlength="6"> |
| </div> |
| <div class="form-group"> |
| <label for="add-user-is-admin">Is Admin:</label> |
| <input type="checkbox" id="add-user-is-admin" name="is_admin" value="true"> |
| </div> |
| <div class="form-group"> |
| <button type="submit"><span>Add User</span><div class="spinner"></div></button> |
| </div> |
| </form> |
| </div> |
| |
| <h3>System Users</h3> |
| <button id="refresh-users-btn"><span>Refresh List</span><div class="spinner"></div></button> |
| <div id="users-table-container"><div class="loading">Loading users...</div></div> |
| </section> |
| |
| |
| </div> |
| |
| <script> |
| // Encapsulate JS in an IIFE |
| (function() { |
| // --- State & Configuration --- |
| let currentSection = 'inventory'; // Default section |
| let isLoggedIn = {{ 'true' if session.get('username') else 'false' }}; |
| let isAdmin = {{ 'true' if session.get('is_admin') else 'false' }}; |
| |
| // --- DOM Element References --- |
| // (Keep all existing references from v3.0) |
| const loginSection = document.getElementById('login-section'); |
| const appSection = document.getElementById('app-section'); |
| const loginForm = document.getElementById('login-form'); |
| const loginError = document.getElementById('login-error'); |
| const userInfo = document.getElementById('user-info'); |
| const userDisplay = document.getElementById('user-display'); |
| const adminDisplay = document.getElementById('admin-display'); |
| const logoutBtn = document.getElementById('logout-btn'); |
| const navButtons = document.querySelectorAll('.nav-btn'); |
| const sections = document.querySelectorAll('section'); |
| const globalStatus = document.getElementById('global-status'); |
| const inventoryTableContainer = document.getElementById('inventory-table-container'); |
| const suppliersTableContainer = document.getElementById('suppliers-table-container'); |
| const suggestionsTableContainer = document.getElementById('suggestions-table-container'); |
| const usersTableContainer = document.getElementById('users-table-container'); |
| const inventoryActionForm = document.getElementById('inventory-action-form'); |
| const addItemForm = document.getElementById('add-item-form'); |
| const addSupplierForm = document.getElementById('add-supplier-form'); |
| const resolveSuggestionForm = document.getElementById('resolve-suggestion-form'); |
| const addUserForm = document.getElementById('add-user-form'); |
| const resolveSuggestionIdSelect = document.getElementById('resolve-suggestion-id'); |
| const resolveActionSelect = document.getElementById('resolve-action'); |
| const receivedQtyGroup = document.getElementById('received-qty-group'); |
| const resolveReceivedQtyInput = document.getElementById('resolve-received-qty'); |
| const refreshInventoryBtn = document.getElementById('refresh-inventory-btn'); |
| const refreshSuppliersBtn = document.getElementById('refresh-suppliers-btn'); |
| const refreshSuggestionsBtn = document.getElementById('refresh-suggestions-btn'); |
| const refreshUsersBtn = document.getElementById('refresh-users-btn'); |
| |
| // --- Utility Functions --- |
| |
| // *** UPDATED/IMPROVED apiRequest function *** |
| async function apiRequest(endpoint, method = 'GET', body = null, button = null) { |
| const options = { |
| method: method, |
| headers: { |
| // Set Content-Type only if body exists, avoids issues with GET/HEAD |
| ...(body && {'Content-Type': 'application/json'}), |
| // Add other headers if needed, e.g., CSRF token |
| } |
| }; |
| if (body) { |
| options.body = JSON.stringify(body); |
| } |
| |
| // Show spinner on button if provided |
| if (button) { |
| button.classList.add('loading'); |
| button.disabled = true; |
| } |
| |
| try { |
| const response = await fetch(endpoint, options); |
| |
| // --- Authentication/Authorization Checks FIRST --- |
| if (response.status === 401) { |
| showStatus("Session expired or not authorized. Reloading login...", true); |
| setTimeout(() => window.location.reload(), 2000); // Reload to show login |
| // Indicate failure to the caller implicitly by not returning data |
| // Or explicitly throw an error handled by the caller's catch block |
| throw new Error("Authentication required"); // Throw to stop execution |
| } |
| if (response.status === 403) { |
| let errorMsg = "Access forbidden."; |
| try { // Try to parse error message from JSON body for 403 |
| const errorData = await response.json(); |
| errorMsg = errorData.message || errorMsg; |
| } catch (parseError) { /* Body wasn't JSON, use default message */ } |
| throw new Error(errorMsg); // Throw specific forbidden error |
| } |
| |
| // --- Check for *ANY* Non-OK Statuses (4xx, 5xx, excluding 401/403) --- |
| if (!response.ok) { |
| let errorMsg = `Request failed: ${response.status} ${response.statusText}`; |
| let errorDetails = ''; |
| try { |
| // Attempt to get error details from JSON body first |
| const errorData = await response.json(); |
| errorDetails = errorData.message || JSON.stringify(errorData); |
| } catch (e) { |
| // Body wasn't JSON, try reading as text |
| try { |
| errorDetails = await response.text(); |
| // Avoid logging full HTML pages if that's what we received |
| if (errorDetails.trim().toLowerCase().startsWith('<!doctype html') || errorDetails.trim().startsWith('<html')) { |
| errorDetails = "(Received unexpected HTML response body)"; |
| } else if (errorDetails.length > 150) { // Truncate long text errors |
| errorDetails = errorDetails.substring(0, 150) + "..."; |
| } |
| } catch (textError) { |
| errorDetails = "(Could not read error response body)"; |
| } |
| } |
| throw new Error(`${errorMsg}. ${errorDetails}`); |
| } |
| |
| // --- If response.ok (2xx status), check Content-Type before parsing --- |
| const contentType = response.headers.get('content-type'); |
| if (!contentType || !contentType.includes('application/json')) { |
| // We expected JSON, but didn't get it, even on success status! |
| let responseBodyPreview = '(Could not read response body)'; |
| try { |
| responseBodyPreview = (await response.text()).substring(0, 100); // Get start of body |
| } catch(e) {/* ignore */} |
| console.warn(`Expected JSON response from ${endpoint}, but received ${contentType || 'unknown content type'}. Body starts: ${responseBodyPreview}`); |
| throw new Error(`Received unexpected content type from server: ${contentType || 'unknown'}. Check server logs.`); |
| } |
| |
| // --- Only parse JSON if status is OK and Content-Type is correct --- |
| const data = await response.json(); |
| return data; // Success case |
| |
| } catch (error) { |
| console.error(`API Request Error during fetch to ${endpoint}:`, error); |
| // Rethrow the error (already constructed meaningfully above) |
| // The calling function's catch block should display it. |
| throw error; |
| } finally { |
| if (button) { |
| button.classList.remove('loading'); |
| button.disabled = false; |
| } |
| } |
| } |
| |
| // Display status messages (Unchanged from v3.0) |
| function showStatus(message, isError = false, element = globalStatus) { |
| if (!element) { console.error("Status element not found"); return; } |
| element.textContent = message; |
| element.className = 'status-message'; // Reset classes |
| element.classList.add(isError ? 'status-error' : 'status-success'); |
| element.classList.remove('hidden'); |
| setTimeout(() => { if(element) element.classList.add('hidden'); }, 5000); |
| } |
| |
| // Create HTML Table from data array (Unchanged from v3.0) |
| function createTable(data, columns) { |
| if (!data || data.length === 0) { |
| return '<p>No data available.</p>'; |
| } |
| let html = '<table><thead><tr>'; |
| columns.forEach(col => { |
| const headerText = col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); |
| html += `<th>${headerText}</th>`; |
| }); |
| html += '</tr></thead><tbody>'; |
| data.forEach(row => { |
| html += '<tr>'; |
| columns.forEach(col => { |
| let cellValue = row[col]; |
| if (typeof cellValue === 'boolean') { |
| cellValue = cellValue ? 'Yes' : 'No'; |
| } else if (cellValue === null || cellValue === undefined) { |
| cellValue = ''; |
| } |
| html += `<td>${cellValue}</td>`; |
| }); |
| html += '</tr>'; |
| }); |
| html += '</tbody></table>'; |
| return html; |
| } |
| |
| // --- Data Loading Functions (Use the new apiRequest, error handling added) --- |
| |
| async function loadInventory(button = null) { |
| inventoryTableContainer.innerHTML = '<div class="loading">Loading inventory...</div>'; |
| try { |
| // Use the robust apiRequest |
| const data = await apiRequest('/api/inventory', 'GET', null, button); |
| // apiRequest now throws on error or returns valid data |
| if (data && data.success) { |
| const columns = ["item_id", "item_name", "description", "current_quantity", "unit", "reorder_level", "preferred_supplier_name", "last_updated_str"]; |
| inventoryTableContainer.innerHTML = createTable(data.inventory, columns); |
| } else if (data) { // Should ideally not happen if apiRequest throws, but defense-in-depth |
| inventoryTableContainer.innerHTML = `<p class="status-error">Failed to load inventory: ${data.message || 'Unknown error'}</p>`; |
| } |
| // No else needed - error case is handled by the catch block now |
| } catch (error) { |
| // Catch errors thrown by apiRequest |
| console.error("Error in loadInventory:", error); |
| // Display the error message constructed by apiRequest |
| inventoryTableContainer.innerHTML = `<p class="status-error">Error loading inventory: ${error.message}</p>`; |
| } |
| } |
| |
| async function loadSuppliers(button = null) { |
| suppliersTableContainer.innerHTML = '<div class="loading">Loading suppliers...</div>'; |
| try { |
| const data = await apiRequest('/api/suppliers', 'GET', null, button); |
| if (data && data.success) { |
| const columns = ["supplier_id", "supplier_name", "contact_info", "notes"]; |
| suppliersTableContainer.innerHTML = createTable(data.suppliers, columns); |
| } else if (data) { |
| suppliersTableContainer.innerHTML = `<p class="status-error">Failed to load suppliers: ${data.message || 'Unknown error'}</p>`; |
| } |
| } catch (error) { |
| console.error("Error in loadSuppliers:", error); |
| suppliersTableContainer.innerHTML = `<p class="status-error">Error loading suppliers: ${error.message}</p>`; |
| } |
| } |
| |
| async function loadSuggestions(button = null) { |
| suggestionsTableContainer.innerHTML = '<div class="loading">Loading suggestions...</div>'; |
| resolveSuggestionIdSelect.innerHTML = '<option value="">-- Loading... --</option>'; |
| try { |
| const data = await apiRequest('/api/suggestions', 'GET', null, button); |
| if (data && data.success) { |
| const columns = ["suggestion_id", "item_name", "suggested_quantity", "unit", "supplier_name", "reason", "created_at_str"]; |
| suggestionsTableContainer.innerHTML = createTable(data.suggestions, columns); |
| |
| resolveSuggestionIdSelect.innerHTML = '<option value="">-- Select Suggestion --</option>'; |
| if (data.suggestions.length > 0) { |
| data.suggestions.forEach(s => { |
| const option = document.createElement('option'); |
| option.value = s.suggestion_id; |
| option.textContent = `ID: ${s.suggestion_id} - ${s.item_name} (Qty: ${s.suggested_quantity})`; |
| resolveSuggestionIdSelect.appendChild(option); |
| }); |
| } else { |
| resolveSuggestionIdSelect.innerHTML = '<option value="">-- No pending suggestions --</option>'; |
| } |
| } else if (data) { |
| suggestionsTableContainer.innerHTML = `<p class="status-error">Failed to load suggestions: ${data.message || 'Unknown error'}</p>`; |
| resolveSuggestionIdSelect.innerHTML = '<option value="">-- Error loading --</option>'; |
| } |
| } catch (error) { |
| console.error("Error in loadSuggestions:", error); |
| suggestionsTableContainer.innerHTML = `<p class="status-error">Error loading suggestions: ${error.message}</p>`; |
| resolveSuggestionIdSelect.innerHTML = '<option value="">-- Error loading --</option>'; |
| } |
| } |
| |
| async function loadUsers(button = null) { |
| usersTableContainer.innerHTML = '<div class="loading">Loading users...</div>'; |
| if (!isAdmin) { |
| usersTableContainer.innerHTML = '<p class="status-error">Admin access required to view users.</p>'; |
| return; |
| } |
| try { |
| const data = await apiRequest('/api/users', 'GET', null, button); |
| if (data && data.success) { |
| const columns = ["username", "is_admin", "created_at"]; |
| usersTableContainer.innerHTML = createTable(data.users, columns); |
| } else if (data) { |
| usersTableContainer.innerHTML = `<p class="status-error">Failed to load users: ${data.message || 'Unknown error'}</p>`; |
| } |
| } catch (error) { |
| console.error("Error in loadUsers:", error); |
| // Check if it was specifically a Forbidden error caught by apiRequest |
| if (error.message.includes("forbidden") || error.message.includes("Access required")) { |
| usersTableContainer.innerHTML = '<p class="status-error">Admin access required.</p>'; |
| } else { |
| usersTableContainer.innerHTML = `<p class="status-error">Error loading users: ${error.message}</p>`; |
| } |
| } |
| } |
| |
| |
| // --- Event Handlers --- |
| // (Login, Logout, Navigation, Inventory Action, Add Item, Add Supplier, Resolve Suggestion, Add User) |
| // These handlers remain largely the same as in v3.0, but now their calls to apiRequest |
| // benefit from the improved error handling within apiRequest itself. |
| // The .catch(error => showStatus(error.message, true)); blocks within each handler |
| // will now display more specific error messages originating from apiRequest. |
| |
| // Example: Login Handler (No change needed, catch block handles errors from apiRequest) |
| loginForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| loginError.classList.add('hidden'); |
| const username = loginForm.username.value; |
| const password = loginForm.password.value; |
| const button = loginForm.querySelector('button[type="submit"]'); |
| |
| try { |
| const data = await apiRequest('/api/login', 'POST', { username, password }, button); |
| if (data && data.success) { // Check for data existence after await |
| isLoggedIn = true; |
| isAdmin = data.user.is_admin; |
| loginSection.classList.add('hidden'); |
| appSection.classList.remove('hidden'); |
| userDisplay.textContent = data.user.username; |
| adminDisplay.textContent = isAdmin ? 'Admin' : 'User'; |
| document.querySelector('.nav-btn[data-section="users"]').classList.toggle('hidden', !isAdmin); |
| navigateTo(currentSection); |
| } |
| // No explicit else needed, apiRequest throws on failure |
| } catch (error) { |
| // Handle errors thrown by apiRequest (e.g., auth failed, network error) |
| showStatus(error.message, true, loginError); // Display error in login form's status area |
| // Reset state just in case |
| isLoggedIn = false; |
| isAdmin = false; |
| } |
| }); |
| |
| // Example: Logout Handler (No change needed) |
| logoutBtn.addEventListener('click', async () => { |
| try { |
| const data = await apiRequest('/api/logout', 'POST'); |
| if (data && data.success) { // Check data exists |
| isLoggedIn = false; |
| isAdmin = false; |
| loginSection.classList.remove('hidden'); |
| appSection.classList.add('hidden'); |
| loginForm.reset(); |
| loginError.classList.add('hidden'); |
| document.querySelector('.nav-btn[data-section="users"]').classList.add('hidden'); |
| inventoryTableContainer.innerHTML = ''; |
| suppliersTableContainer.innerHTML = ''; |
| suggestionsTableContainer.innerHTML = ''; |
| usersTableContainer.innerHTML = ''; |
| navigateTo('inventory'); |
| } |
| } catch (error) { |
| // Handle potential errors during logout API call |
| showStatus(error.message, true); |
| } |
| }); |
| |
| // ... (Keep other event handlers: navButtons, inventoryActionForm, addItemForm, etc. as they were in v3.0) ... |
| // Ensure all handlers use `try...catch` around `apiRequest` calls and use `showStatus(error.message, true)` in the catch block. |
| |
| // --- NEW versions of handlers with robust catch blocks --- |
| // Navigation Handler |
| navButtons.forEach(button => { |
| button.addEventListener('click', () => { |
| const sectionId = button.getAttribute('data-section'); |
| if (!isLoggedIn) { showStatus("Please login.", true); return; } |
| if (sectionId === 'users' && !isAdmin) { showStatus("Admin access required.", true); return; } |
| navigateTo(sectionId); |
| }); |
| }); |
| |
| // Inventory Action Handler |
| inventoryActionForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const formData = new FormData(inventoryActionForm); |
| const action_type = formData.get('action_type'); |
| const item_id = formData.get('item_id'); |
| let quantity_change = parseFloat(formData.get('quantity_change')); |
| const reason = formData.get('reason') || `Action: ${action_type}`; |
| const button = inventoryActionForm.querySelector('button[type="submit"]'); |
| |
| if (isNaN(quantity_change)) { showStatus("Quantity must be a number.", true); return; } |
| // ... (quantity adjustment logic) ... |
| if (action_type === 'sale') { if (quantity_change > 0) quantity_change = -quantity_change; else { showStatus("Sale quantity must be positive.", true); return; } } |
| else if (action_type === 'delivery') { if (quantity_change < 0) { showStatus("Delivery quantity must be positive.", true); return; } } |
| |
| const payload = { quantity_change, reason }; |
| try { |
| const data = await apiRequest(`/api/inventory/${item_id}/quantity`, 'POST', payload, button); |
| if (data && data.success) { |
| showStatus(data.message || "Inventory updated.", false); |
| inventoryActionForm.reset(); |
| loadInventory(); loadSuggestions(); |
| } |
| } catch (error) { showStatus(error.message, true); } |
| }); |
| |
| // Add Item Handler |
| addItemForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const formData = new FormData(addItemForm); |
| const payload = Object.fromEntries(formData.entries()); |
| // ... (payload parsing logic) ... |
| payload.initial_quantity = parseFloat(payload.initial_quantity || 0); |
| payload.reorder_level = payload.reorder_level ? parseFloat(payload.reorder_level) : null; |
| payload.preferred_supplier_id = payload.preferred_supplier_id ? parseInt(payload.preferred_supplier_id) : null; |
| if (isNaN(payload.initial_quantity) || (payload.reorder_level !== null && isNaN(payload.reorder_level)) || (payload.preferred_supplier_id !== null && isNaN(payload.preferred_supplier_id))) { showStatus("Invalid number input.", true); return; } |
| |
| const button = addItemForm.querySelector('button[type="submit"]'); |
| try { |
| const data = await apiRequest('/api/inventory', 'POST', payload, button); |
| if (data && data.success) { |
| showStatus(data.message || "Item added.", false); |
| addItemForm.reset(); loadInventory(); |
| } |
| } catch (error) { showStatus(error.message, true); } |
| }); |
| |
| // Add Supplier Handler |
| addSupplierForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const formData = new FormData(addSupplierForm); |
| const payload = Object.fromEntries(formData.entries()); |
| const button = addSupplierForm.querySelector('button[type="submit"]'); |
| try { |
| const data = await apiRequest('/api/suppliers', 'POST', payload, button); |
| if (data && data.success) { |
| showStatus(data.message || "Supplier added.", false); |
| addSupplierForm.reset(); loadSuppliers(); |
| } |
| } catch (error) { showStatus(error.message, true); } |
| }); |
| |
| // Resolve Suggestion Handler |
| resolveSuggestionForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const formData = new FormData(resolveSuggestionForm); |
| const suggestion_id = formData.get('suggestion_id'); |
| const action = formData.get('action'); |
| let received_quantity = formData.get('received_quantity'); |
| const button = resolveSuggestionForm.querySelector('button[type="submit"]'); |
| |
| if (!suggestion_id) { showStatus("Select suggestion.", true); return; } |
| // ... (payload preparation logic) ... |
| const payload = { action }; |
| if (action === 'received') { if (!received_quantity || isNaN(parseFloat(received_quantity)) || parseFloat(received_quantity) <= 0) { showStatus("Valid positive quantity required.", true); return; } payload.received_quantity = parseFloat(received_quantity); } else { payload.received_quantity = null; } |
| |
| try { |
| const data = await apiRequest(`/api/suggestions/${suggestion_id}/resolve`, 'POST', payload, button); |
| if (data && data.success) { |
| showStatus(data.message || "Suggestion resolved.", false); |
| resolveSuggestionForm.reset(); receivedQtyGroup.classList.add('hidden'); |
| loadSuggestions(); if (action === 'received') { loadInventory(); } |
| } |
| } catch (error) { showStatus(error.message, true); } |
| }); |
| |
| // Add User Handler |
| addUserForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const formData = new FormData(addUserForm); |
| const username = formData.get('username'); |
| const password = formData.get('password'); |
| const is_admin = formData.get('is_admin') === 'true'; |
| const button = addUserForm.querySelector('button[type="submit"]'); |
| |
| // ... (basic validation) ... |
| if (!username || !password) { showStatus("Username and password required.", true); return; } if (username.length < 3) { showStatus("Username too short.", true); return; } if (password.length < 6) { showStatus("Password too short.", true); return; } |
| |
| const payload = { username, password, is_admin }; |
| try { |
| const data = await apiRequest('/api/users', 'POST', payload, button); |
| if (data && data.success) { |
| showStatus(data.message || "User added.", false); |
| addUserForm.reset(); loadUsers(); |
| } |
| } catch (error) { showStatus(error.message, true); } |
| }); |
| |
| // --- End Event Handlers --- |
| |
| |
| // Refresh Button Handlers (Unchanged) |
| refreshInventoryBtn.addEventListener('click', () => loadInventory(refreshInventoryBtn)); |
| refreshSuppliersBtn.addEventListener('click', () => loadSuppliers(refreshSuppliersBtn)); |
| refreshSuggestionsBtn.addEventListener('click', () => loadSuggestions(refreshSuggestionsBtn)); |
| refreshUsersBtn.addEventListener('click', () => loadUsers(refreshUsersBtn)); |
| |
| // Accordion Handler (Unchanged) |
| document.querySelectorAll('.accordion').forEach(accordion => { |
| accordion.addEventListener('click', function() { this.classList.toggle('active'); const panel = this.nextElementSibling; if (panel.style.display === "block") { panel.style.display = "none"; this.innerHTML = this.innerHTML.replace('▲', '▼'); } else { panel.style.display = "block"; this.innerHTML = this.innerHTML.replace('▼', '▲'); } }); |
| }); |
| |
| // --- Initial Load / State Check (Unchanged) --- |
| function initialize() { |
| if (isLoggedIn) { |
| loginSection.classList.add('hidden'); |
| appSection.classList.remove('hidden'); |
| document.querySelector('.nav-btn[data-section="users"]').classList.toggle('hidden', !isAdmin); |
| setTimeout(() => navigateTo(currentSection), 50); |
| } else { |
| loginSection.classList.remove('hidden'); |
| appSection.classList.add('hidden'); |
| } |
| document.querySelectorAll('.panel').forEach(panel => panel.style.display = 'none'); |
| document.querySelectorAll('.accordion').forEach(acc => acc.innerHTML = acc.innerHTML.replace('▲', '▼')); |
| } |
| document.addEventListener('DOMContentLoaded', initialize); |
| |
| // Expose navigateTo globally for debugging if needed |
| // window.navigateTo = navigateTo; |
| |
| })(); // End IIFE |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| |
| app = Flask(__name__) |
| app.secret_key = SECRET_KEY |
|
|
| |
|
|
| @app.route('/') |
| def index(): |
| """Serves the main application page or login page based on session.""" |
| |
| |
| return render_template_string(HTML_TEMPLATE, session=session) |
|
|
|
|
| @app.route('/api/login', methods=['POST']) |
| def api_login(): |
| """Handles user login attempt.""" |
| data = request.get_json() |
| username = data.get('username') |
| password = data.get('password') |
|
|
| if not username or not password: |
| |
| log_event(None, 'LOGIN_FAILURE', {'reason': 'Missing credentials', 'username_attempt': username, 'ip_address': request.remote_addr}) |
| return jsonify({"success": False, "message": "Username and password are required"}), 400 |
|
|
| conn = None |
| try: |
| conn = get_db_connection() |
| cursor = conn.cursor() |
| cursor.execute("SELECT username, hashed_password, salt, is_admin FROM users WHERE username = ?", (username,)) |
| user_row = cursor.fetchone() |
| conn.close() |
|
|
| if user_row: |
| stored_hash = user_row['hashed_password'] |
| salt = user_row['salt'] |
| if verify_password(stored_hash, salt, password): |
| |
| session['username'] = user_row['username'] |
| session['is_admin'] = bool(user_row['is_admin']) |
| session.permanent = True |
| log_event(username, 'LOGIN_SUCCESS', {'ip_address': request.remote_addr}) |
| |
| user_info = {"username": user_row['username'], "is_admin": bool(user_row['is_admin'])} |
| return jsonify({"success": True, "message": "Login successful", "user": user_info}) |
| else: |
| log_event(username, 'LOGIN_FAILURE', {'reason': 'Invalid password', 'ip_address': request.remote_addr}) |
| return jsonify({"success": False, "message": "Invalid username or password"}), 401 |
| else: |
| log_event(username, 'LOGIN_FAILURE', {'reason': 'User not found', 'username_attempt': username, 'ip_address': request.remote_addr}) |
| return jsonify({"success": False, "message": "Invalid username or password"}), 401 |
|
|
| except sqlite3.Error as e: |
| print(f"ERROR: Database error during login for user {username}: {e}") |
| log_event(username, 'LOGIN_ERROR', {'error': str(e), 'ip_address': request.remote_addr}) |
| if conn: conn.close() |
| return jsonify({"success": False, "message": "Server error during login"}), 500 |
| except Exception as e: |
| print(f"ERROR: Unexpected error during login for user {username}: {e}") |
| |
| log_event(username or 'Unknown', 'LOGIN_UNEXPECTED_ERROR', {'error': str(e), 'ip_address': request.remote_addr}) |
| if conn: conn.close() |
| return jsonify({"success": False, "message": "An unexpected server error occurred"}), 500 |
|
|
|
|
| @app.route('/api/logout', methods=['POST']) |
| @login_required |
| def api_logout(): |
| """Logs the user out by clearing the session.""" |
| username = session.get('username', 'Unknown') |
| session.pop('username', None) |
| session.pop('is_admin', None) |
| |
| session.clear() |
| log_event(username, 'LOGOUT', {'ip_address': request.remote_addr}) |
| return jsonify({"success": True, "message": "Logged out successfully"}) |
|
|
| @app.route('/api/status') |
| def api_status(): |
| """Checks if the user is currently logged in.""" |
| |
| if 'username' in session: |
| user_info = {"username": session['username'], "is_admin": session.get('is_admin', False)} |
| return jsonify({"logged_in": True, "user": user_info}) |
| else: |
| return jsonify({"logged_in": False}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
|
|
| @app.route('/api/users', methods=['GET']) |
| @admin_required |
| def api_get_users(): |
| """API endpoint to get list of users.""" |
| try: |
| users = get_users_logic() |
| |
| safe_users = [{"username": u['username'], "is_admin": bool(u['is_admin']), "created_at": u['created_at']} for u in users] |
| return jsonify({"success": True, "users": safe_users}) |
| except Exception as e: |
| print(f"API ERROR: /api/users GET: {e}") |
| |
| log_event(session.get('username', 'Unknown Admin Attempt'), 'GET_USERS_API_ERROR', {'error': str(e)}) |
| return jsonify({"success": False, "message": f"Failed to retrieve users: {e}"}), 500 |
|
|
| @app.route('/api/users', methods=['POST']) |
| @admin_required |
| def api_add_user(): |
| """API endpoint to add a new user.""" |
| data = request.get_json() |
| if not data: |
| log_event(session.get('username', 'Unknown Admin Attempt'), 'ADD_USER_API_FAILURE', {'reason': 'No data'}) |
| return jsonify({"success": False, "message": "Invalid request body"}), 400 |
|
|
| username = data.get('username') |
| password = data.get('password') |
| is_admin = data.get('is_admin', False) |
| creating_user_username = session.get('username', 'Unknown Admin') |
|
|
| |
| success, message = add_user_logic(username, password, bool(is_admin), creating_user_username) |
|
|
| if success: |
| return jsonify({"success": True, "message": message}) |
| else: |
| |
| status_code = 409 if 'already exists' in message else 400 |
| return jsonify({"success": False, "message": message}), status_code |
|
|
| |
| |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| print("INFO: Starting Business Automation Suite v3.0 (Flask)...") |
| print(f"INFO: Using database file: {os.path.abspath(DB_NAME)}") |
|
|
| |
| try: |
| setup_database() |
| except Exception as e: |
| print(f"FATAL: Could not initialize database. Exiting. Error: {e}") |
| exit(1) |
|
|
| print("INFO: Launching Flask application...") |
| |
| |
| |
| |
| app.run(host='0.0.0.0', port=7860, debug=False) |
| print("INFO: Application stopped.") |