Spaces:
Runtime error
Runtime error
Ashwin commited on
Commit ·
8dbbe64
1
Parent(s): 0400ba7
Implement role-based access control, admin portal, and global loader UI
Browse files- app.py +4 -2
- celery_app.py +1 -1
- db.py +50 -10
- routes/system.py +10 -6
- static/js/api.js +3 -1
- static/js/app.js +454 -170
- static/js/components/tasks.js +3 -6
- static/js/router.js +9 -1
- static/js/utils.js +47 -0
- static/js/views/mockups.js +420 -0
- static/styles.css +67 -0
- templates/index.html +55 -40
app.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from dotenv import load_dotenv
|
| 2 |
|
| 3 |
load_dotenv()
|
|
@@ -13,8 +14,10 @@ from routes.system import stages_bp, activities_bp, stats_bp, columns_bp, settin
|
|
| 13 |
from routes.ai import ai_bp
|
| 14 |
from routes.learning import learning_bp
|
| 15 |
from routes.stt import stt_bp
|
|
|
|
| 16 |
|
| 17 |
app = Flask(__name__)
|
|
|
|
| 18 |
|
| 19 |
from db import init_db, is_supabase_active
|
| 20 |
# Ensure database is initialized
|
|
@@ -28,6 +31,7 @@ def index():
|
|
| 28 |
return render_template("index.html")
|
| 29 |
|
| 30 |
# Register Blueprints
|
|
|
|
| 31 |
app.register_blueprint(companies_bp, url_prefix='/api/companies')
|
| 32 |
app.register_blueprint(contacts_bp, url_prefix='/api/contacts')
|
| 33 |
app.register_blueprint(stages_bp, url_prefix='/api/stages')
|
|
@@ -43,8 +47,6 @@ app.register_blueprint(learning_bp, url_prefix='/api/learning')
|
|
| 43 |
app.register_blueprint(bootstrap_bp, url_prefix='/api/bootstrap')
|
| 44 |
app.register_blueprint(stt_bp)
|
| 45 |
|
| 46 |
-
import os
|
| 47 |
-
|
| 48 |
if __name__ == "__main__":
|
| 49 |
port = int(os.environ.get("PORT", 5000))
|
| 50 |
app.run(host="0.0.0.0", port=port, debug=True)
|
|
|
|
| 1 |
+
import os
|
| 2 |
from dotenv import load_dotenv
|
| 3 |
|
| 4 |
load_dotenv()
|
|
|
|
| 14 |
from routes.ai import ai_bp
|
| 15 |
from routes.learning import learning_bp
|
| 16 |
from routes.stt import stt_bp
|
| 17 |
+
from routes.auth import auth_bp
|
| 18 |
|
| 19 |
app = Flask(__name__)
|
| 20 |
+
app.secret_key = os.environ.get("SECRET_KEY", "prospectiq_secure_dev_session_key_98765")
|
| 21 |
|
| 22 |
from db import init_db, is_supabase_active
|
| 23 |
# Ensure database is initialized
|
|
|
|
| 31 |
return render_template("index.html")
|
| 32 |
|
| 33 |
# Register Blueprints
|
| 34 |
+
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
| 35 |
app.register_blueprint(companies_bp, url_prefix='/api/companies')
|
| 36 |
app.register_blueprint(contacts_bp, url_prefix='/api/contacts')
|
| 37 |
app.register_blueprint(stages_bp, url_prefix='/api/stages')
|
|
|
|
| 47 |
app.register_blueprint(bootstrap_bp, url_prefix='/api/bootstrap')
|
| 48 |
app.register_blueprint(stt_bp)
|
| 49 |
|
|
|
|
|
|
|
| 50 |
if __name__ == "__main__":
|
| 51 |
port = int(os.environ.get("PORT", 5000))
|
| 52 |
app.run(host="0.0.0.0", port=port, debug=True)
|
celery_app.py
CHANGED
|
@@ -9,7 +9,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
|
| 9 |
|
| 10 |
from celery import Celery
|
| 11 |
|
| 12 |
-
redis_url = os.environ.get("REDIS_URL", "redis://
|
| 13 |
broker_url = os.environ.get("CELERY_BROKER_URL", redis_url)
|
| 14 |
backend_url = os.environ.get("CELERY_RESULT_BACKEND", redis_url)
|
| 15 |
|
|
|
|
| 9 |
|
| 10 |
from celery import Celery
|
| 11 |
|
| 12 |
+
redis_url = os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0")
|
| 13 |
broker_url = os.environ.get("CELERY_BROKER_URL", redis_url)
|
| 14 |
backend_url = os.environ.get("CELERY_RESULT_BACKEND", redis_url)
|
| 15 |
|
db.py
CHANGED
|
@@ -114,6 +114,8 @@ class PostgresCursorWrapper:
|
|
| 114 |
sql = sql.replace("INSERT OR IGNORE INTO", "INSERT INTO") + " ON CONFLICT DO NOTHING"
|
| 115 |
|
| 116 |
is_insert = sql_upper.startswith("INSERT")
|
|
|
|
|
|
|
| 117 |
|
| 118 |
# Invalidate stage or user caches automatically on database writes
|
| 119 |
is_write = sql_upper.startswith("INSERT") or sql_upper.startswith("UPDATE") or sql_upper.startswith("DELETE")
|
|
@@ -132,6 +134,13 @@ class PostgresCursorWrapper:
|
|
| 132 |
self.cursor.execute(sql)
|
| 133 |
|
| 134 |
self._lastrowid = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
return self
|
| 137 |
|
|
@@ -461,16 +470,47 @@ def init_db():
|
|
| 461 |
from flask import request
|
| 462 |
|
| 463 |
def get_request_user():
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
try:
|
| 468 |
-
user_id =
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
|
| 475 |
def get_user_access_level(db_conn, user_id, user_role, company_id):
|
| 476 |
if user_role in ('Super Admin', 'Admin'):
|
|
@@ -530,7 +570,7 @@ def get_redis_client():
|
|
| 530 |
os.environ.get("REDIS_URL")
|
| 531 |
or os.environ.get("CELERY_BROKER_URL")
|
| 532 |
or os.environ.get("CELERY_RESULT_BACKEND")
|
| 533 |
-
or "redis://
|
| 534 |
)
|
| 535 |
client = redis.Redis.from_url(redis_url, decode_responses=True, socket_connect_timeout=2)
|
| 536 |
client.ping()
|
|
|
|
| 114 |
sql = sql.replace("INSERT OR IGNORE INTO", "INSERT INTO") + " ON CONFLICT DO NOTHING"
|
| 115 |
|
| 116 |
is_insert = sql_upper.startswith("INSERT")
|
| 117 |
+
if is_insert and "RETURNING" not in sql_upper and "ROLE_PERMISSIONS" not in sql_upper:
|
| 118 |
+
sql = sql.rstrip().rstrip(';') + " RETURNING id"
|
| 119 |
|
| 120 |
# Invalidate stage or user caches automatically on database writes
|
| 121 |
is_write = sql_upper.startswith("INSERT") or sql_upper.startswith("UPDATE") or sql_upper.startswith("DELETE")
|
|
|
|
| 134 |
self.cursor.execute(sql)
|
| 135 |
|
| 136 |
self._lastrowid = None
|
| 137 |
+
if is_insert and "RETURNING" in sql.upper():
|
| 138 |
+
try:
|
| 139 |
+
row = self.cursor.fetchone()
|
| 140 |
+
if row:
|
| 141 |
+
self._lastrowid = row[0]
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
|
| 145 |
return self
|
| 146 |
|
|
|
|
| 470 |
from flask import request
|
| 471 |
|
| 472 |
def get_request_user():
|
| 473 |
+
from flask import session
|
| 474 |
+
try:
|
| 475 |
+
if 'user_id' in session:
|
| 476 |
+
return session['user_id'], session['user_role'], session['user_name']
|
| 477 |
+
except Exception:
|
| 478 |
+
pass
|
| 479 |
+
|
| 480 |
+
try:
|
| 481 |
+
user_id = request.headers.get("X-User-Id")
|
| 482 |
+
role = request.headers.get("X-User-Role")
|
| 483 |
+
name = request.headers.get("X-User-Name")
|
| 484 |
+
try:
|
| 485 |
+
user_id = int(user_id) if user_id else 2
|
| 486 |
+
except (ValueError, TypeError):
|
| 487 |
+
user_id = 2
|
| 488 |
+
role = role if role else "BD Rep"
|
| 489 |
+
name = name if name else "bd rep1"
|
| 490 |
+
return user_id, role, name
|
| 491 |
+
except Exception:
|
| 492 |
+
return 2, "BD Rep", "bd rep1"
|
| 493 |
+
|
| 494 |
+
def get_request_tenant_id():
|
| 495 |
+
from flask import session
|
| 496 |
+
try:
|
| 497 |
+
if 'tenant_id' in session:
|
| 498 |
+
return session['tenant_id']
|
| 499 |
+
except Exception:
|
| 500 |
+
pass
|
| 501 |
+
|
| 502 |
try:
|
| 503 |
+
user_id, _, _ = get_request_user()
|
| 504 |
+
conn = get_db_connection()
|
| 505 |
+
cursor = conn.cursor()
|
| 506 |
+
cursor.execute("SELECT tenant_id FROM users WHERE id = ?", (user_id,))
|
| 507 |
+
row = cursor.fetchone()
|
| 508 |
+
conn.close()
|
| 509 |
+
if row:
|
| 510 |
+
return row[0]
|
| 511 |
+
except Exception:
|
| 512 |
+
pass
|
| 513 |
+
return 1
|
| 514 |
|
| 515 |
def get_user_access_level(db_conn, user_id, user_role, company_id):
|
| 516 |
if user_role in ('Super Admin', 'Admin'):
|
|
|
|
| 570 |
os.environ.get("REDIS_URL")
|
| 571 |
or os.environ.get("CELERY_BROKER_URL")
|
| 572 |
or os.environ.get("CELERY_RESULT_BACKEND")
|
| 573 |
+
or "redis://127.0.0.1:6379/0"
|
| 574 |
)
|
| 575 |
client = redis.Redis.from_url(redis_url, decode_responses=True, socket_connect_timeout=2)
|
| 576 |
client.ping()
|
routes/system.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import re
|
| 2 |
from flask import Blueprint, jsonify, request
|
| 3 |
-
from db import get_db_connection, dict_from_row, get_request_user, get_user_access_level
|
| 4 |
from services.ai_service import base_company_context_status, save_base_company_context
|
| 5 |
|
| 6 |
stages_bp = Blueprint('stages', __name__)
|
|
@@ -416,14 +416,16 @@ def api_bootstrap():
|
|
| 416 |
cache_set("prospectiq:stages", stages, timeout=3600)
|
| 417 |
|
| 418 |
# Fetch companies
|
|
|
|
| 419 |
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 420 |
cursor.execute("""
|
| 421 |
SELECT c.*, s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text, u.name as rep_name
|
| 422 |
FROM companies c
|
| 423 |
JOIN stages s ON c.stage_id = s.id
|
| 424 |
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
|
|
|
| 425 |
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 426 |
-
""")
|
| 427 |
else:
|
| 428 |
cursor.execute("""
|
| 429 |
SELECT
|
|
@@ -441,8 +443,9 @@ def api_bootstrap():
|
|
| 441 |
FROM companies c
|
| 442 |
JOIN stages s ON c.stage_id = s.id
|
| 443 |
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
|
|
|
| 444 |
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 445 |
-
""", (user_id, user_id, user_id, user_id, user_id, user_id, user_id, user_id))
|
| 446 |
companies = [dict_from_row(r) for r in cursor.fetchall()]
|
| 447 |
|
| 448 |
# Fetch contacts
|
|
@@ -451,16 +454,17 @@ def api_bootstrap():
|
|
| 451 |
SELECT co.*, cp.name as company_name
|
| 452 |
FROM contacts co
|
| 453 |
JOIN companies cp ON co.company_id = cp.id
|
|
|
|
| 454 |
ORDER BY co.first_name ASC
|
| 455 |
-
""")
|
| 456 |
else:
|
| 457 |
cursor.execute("""
|
| 458 |
SELECT co.*, cp.name as company_name
|
| 459 |
FROM contacts co
|
| 460 |
JOIN companies cp ON co.company_id = cp.id
|
| 461 |
-
WHERE cp.assigned_rep_id = ?
|
| 462 |
ORDER BY co.first_name ASC
|
| 463 |
-
""", (
|
| 464 |
contacts = [dict_from_row(r) for r in cursor.fetchall()]
|
| 465 |
|
| 466 |
# Fetch company columns schema if not cached
|
|
|
|
| 1 |
import re
|
| 2 |
from flask import Blueprint, jsonify, request
|
| 3 |
+
from db import get_db_connection, dict_from_row, get_request_user, get_user_access_level, get_request_tenant_id
|
| 4 |
from services.ai_service import base_company_context_status, save_base_company_context
|
| 5 |
|
| 6 |
stages_bp = Blueprint('stages', __name__)
|
|
|
|
| 416 |
cache_set("prospectiq:stages", stages, timeout=3600)
|
| 417 |
|
| 418 |
# Fetch companies
|
| 419 |
+
tenant_id = get_request_tenant_id()
|
| 420 |
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 421 |
cursor.execute("""
|
| 422 |
SELECT c.*, s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text, u.name as rep_name
|
| 423 |
FROM companies c
|
| 424 |
JOIN stages s ON c.stage_id = s.id
|
| 425 |
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
| 426 |
+
WHERE c.tenant_id = ?
|
| 427 |
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 428 |
+
""", (tenant_id,))
|
| 429 |
else:
|
| 430 |
cursor.execute("""
|
| 431 |
SELECT
|
|
|
|
| 443 |
FROM companies c
|
| 444 |
JOIN stages s ON c.stage_id = s.id
|
| 445 |
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
| 446 |
+
WHERE c.tenant_id = ?
|
| 447 |
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 448 |
+
""", (user_id, user_id, user_id, user_id, user_id, user_id, user_id, user_id, tenant_id))
|
| 449 |
companies = [dict_from_row(r) for r in cursor.fetchall()]
|
| 450 |
|
| 451 |
# Fetch contacts
|
|
|
|
| 454 |
SELECT co.*, cp.name as company_name
|
| 455 |
FROM contacts co
|
| 456 |
JOIN companies cp ON co.company_id = cp.id
|
| 457 |
+
WHERE cp.tenant_id = ?
|
| 458 |
ORDER BY co.first_name ASC
|
| 459 |
+
""", (tenant_id,))
|
| 460 |
else:
|
| 461 |
cursor.execute("""
|
| 462 |
SELECT co.*, cp.name as company_name
|
| 463 |
FROM contacts co
|
| 464 |
JOIN companies cp ON co.company_id = cp.id
|
| 465 |
+
WHERE cp.tenant_id = ? AND cp.assigned_rep_id = ?
|
| 466 |
ORDER BY co.first_name ASC
|
| 467 |
+
""", (tenant_id, user_id))
|
| 468 |
contacts = [dict_from_row(r) for r in cursor.fetchall()]
|
| 469 |
|
| 470 |
# Fetch company columns schema if not cached
|
static/js/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
async function loadDataFromDb() {
|
|
|
|
| 2 |
try {
|
| 3 |
-
// Try the combined bootstrap endpoint first (single round-trip)
|
| 4 |
const bootstrapRes = await fetch('/api/bootstrap');
|
| 5 |
if (bootstrapRes.ok) {
|
| 6 |
const data = await bootstrapRes.json();
|
|
@@ -101,6 +101,8 @@ async function loadDataFromDb() {
|
|
| 101 |
}
|
| 102 |
} catch (err) {
|
| 103 |
console.error("Failed to load data from database: ", err);
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
|
|
|
|
| 1 |
async function loadDataFromDb() {
|
| 2 |
+
showGlobalLoader('Synchronizing workspace data...');
|
| 3 |
try {
|
|
|
|
| 4 |
const bootstrapRes = await fetch('/api/bootstrap');
|
| 5 |
if (bootstrapRes.ok) {
|
| 6 |
const data = await bootstrapRes.json();
|
|
|
|
| 101 |
}
|
| 102 |
} catch (err) {
|
| 103 |
console.error("Failed to load data from database: ", err);
|
| 104 |
+
} finally {
|
| 105 |
+
hideGlobalLoader();
|
| 106 |
}
|
| 107 |
}
|
| 108 |
|
static/js/app.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
| 1 |
|
|
|
|
| 2 |
// Global fetch interceptor to inject simulated user context
|
| 3 |
(function() {
|
| 4 |
const originalFetch = window.fetch;
|
| 5 |
window.fetch = async function(url, options = {}) {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
options.headers = options.headers || {};
|
| 11 |
if (options.headers instanceof Headers) {
|
|
@@ -25,6 +36,217 @@
|
|
| 25 |
};
|
| 26 |
})();
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
function commandEscapeHtml(value) {
|
| 29 |
const div = document.createElement('div');
|
| 30 |
div.textContent = value == null ? '' : String(value);
|
|
@@ -328,9 +550,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
| 328 |
|
| 329 |
// Check if collapsed state was saved in localStorage
|
| 330 |
const isCollapsed = localStorage.getItem('prospectiq-sidebar-collapsed') === 'true';
|
|
|
|
| 331 |
if (isCollapsed) {
|
| 332 |
document.body.classList.add('sidebar-collapsed');
|
| 333 |
-
if (
|
|
|
|
|
|
|
| 334 |
if (sidebarLogoText) sidebarLogoText.textContent = 'PIQ';
|
| 335 |
}
|
| 336 |
|
|
@@ -339,25 +564,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
| 339 |
const willCollapse = !document.body.classList.contains('sidebar-collapsed');
|
| 340 |
document.body.classList.toggle('sidebar-collapsed', willCollapse);
|
| 341 |
localStorage.setItem('prospectiq-sidebar-collapsed', willCollapse);
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
profileDropdown.style.left = '';
|
| 352 |
-
profileDropdown.style.top = '';
|
| 353 |
-
profileDropdown.style.width = '';
|
| 354 |
-
}
|
| 355 |
-
if (profileTrigger) {
|
| 356 |
-
profileTrigger.setAttribute('aria-expanded', 'false');
|
| 357 |
-
}
|
| 358 |
-
// Trigger window resize to adjust layout if needed
|
| 359 |
-
window.dispatchEvent(new Event('resize'));
|
| 360 |
-
});
|
| 361 |
}
|
| 362 |
|
| 363 |
const searchInput = document.getElementById('global-command-bar');
|
|
@@ -388,170 +603,239 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
| 388 |
updatePlaceholder();
|
| 389 |
window.addEventListener('resize', updatePlaceholder);
|
| 390 |
|
| 391 |
-
//
|
|
|
|
|
|
|
|
|
|
| 392 |
const profileTrigger = document.getElementById('user-profile-trigger');
|
| 393 |
const profileDropdown = document.getElementById('user-profile-dropdown');
|
| 394 |
|
| 395 |
if (profileTrigger && profileDropdown) {
|
| 396 |
-
const
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
const
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
if (avatarEl) {
|
| 409 |
-
// Determine initials
|
| 410 |
-
let initials = name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
| 411 |
-
if (name === 'bd rep1') initials = 'R1';
|
| 412 |
-
else if (name === 'bd rep2') initials = 'R2';
|
| 413 |
-
else if (name === 'bd rep3') initials = 'R3';
|
| 414 |
-
else if (name === 'admin1') initials = 'A1';
|
| 415 |
-
else if (name === 'viewer1') initials = 'V1';
|
| 416 |
-
avatarEl.textContent = initials;
|
| 417 |
-
|
| 418 |
-
// Clear existing classes and add the custom role-based theme color
|
| 419 |
-
avatarEl.className = 'user-avatar';
|
| 420 |
-
if (role === 'Admin') {
|
| 421 |
-
avatarEl.classList.add('admin');
|
| 422 |
-
} else if (role === 'BD Rep') {
|
| 423 |
-
avatarEl.classList.add('bd-rep');
|
| 424 |
-
} else {
|
| 425 |
-
avatarEl.classList.add('viewer');
|
| 426 |
-
}
|
| 427 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
if (
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
});
|
| 439 |
-
}
|
| 440 |
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
profileDropdown.classList.remove('is-floating');
|
| 446 |
-
profileDropdown.style.left = '';
|
| 447 |
-
profileDropdown.style.top = '';
|
| 448 |
-
profileDropdown.style.width = '';
|
| 449 |
-
};
|
| 450 |
-
|
| 451 |
-
const positionProfileDropdown = () => {
|
| 452 |
-
const collapsedDesktop = document.body.classList.contains('sidebar-collapsed') && window.innerWidth >= 992;
|
| 453 |
-
if (!collapsedDesktop) {
|
| 454 |
-
resetProfileDropdownPosition();
|
| 455 |
-
return;
|
| 456 |
-
}
|
| 457 |
-
|
| 458 |
-
profileDropdown.classList.add('is-floating');
|
| 459 |
-
profileDropdown.style.width = '220px';
|
| 460 |
-
|
| 461 |
-
const triggerRect = profileTrigger.getBoundingClientRect();
|
| 462 |
-
const sidebarRect = document.querySelector('.sidebar')?.getBoundingClientRect();
|
| 463 |
-
const dropdownRect = profileDropdown.getBoundingClientRect();
|
| 464 |
-
const gap = 10;
|
| 465 |
-
const viewportPadding = 12;
|
| 466 |
-
const dropdownWidth = dropdownRect.width || 220;
|
| 467 |
-
const dropdownHeight = dropdownRect.height || 260;
|
| 468 |
-
const anchorLeft = Math.max(
|
| 469 |
-
triggerRect.right + gap,
|
| 470 |
-
(sidebarRect?.right || triggerRect.right) + gap
|
| 471 |
-
);
|
| 472 |
-
|
| 473 |
-
const left = Math.min(
|
| 474 |
-
anchorLeft,
|
| 475 |
-
window.innerWidth - dropdownWidth - viewportPadding
|
| 476 |
-
);
|
| 477 |
-
const top = Math.min(
|
| 478 |
-
Math.max(viewportPadding, triggerRect.bottom - dropdownHeight),
|
| 479 |
-
window.innerHeight - dropdownHeight - viewportPadding
|
| 480 |
-
);
|
| 481 |
-
|
| 482 |
-
profileDropdown.style.left = `${Math.max(viewportPadding, left)}px`;
|
| 483 |
-
profileDropdown.style.top = `${Math.max(viewportPadding, top)}px`;
|
| 484 |
-
};
|
| 485 |
-
|
| 486 |
-
const openProfileDropdown = () => {
|
| 487 |
-
profileDropdown.style.display = 'block';
|
| 488 |
-
profileTrigger.setAttribute('aria-expanded', 'true');
|
| 489 |
-
positionProfileDropdown();
|
| 490 |
-
};
|
| 491 |
-
|
| 492 |
-
const closeProfileDropdown = () => {
|
| 493 |
-
profileDropdown.style.display = 'none';
|
| 494 |
-
profileTrigger.setAttribute('aria-expanded', 'false');
|
| 495 |
-
resetProfileDropdownPosition();
|
| 496 |
-
};
|
| 497 |
-
|
| 498 |
-
// Toggle dropdown on click
|
| 499 |
-
profileTrigger.addEventListener('click', (e) => {
|
| 500 |
-
e.stopPropagation();
|
| 501 |
-
const isOpen = profileDropdown.style.display === 'block';
|
| 502 |
-
if (isOpen) closeProfileDropdown();
|
| 503 |
-
else openProfileDropdown();
|
| 504 |
-
});
|
| 505 |
-
|
| 506 |
-
// Close dropdown when clicking anywhere else
|
| 507 |
-
document.addEventListener('click', () => {
|
| 508 |
-
closeProfileDropdown();
|
| 509 |
-
});
|
| 510 |
-
|
| 511 |
-
window.addEventListener('resize', () => {
|
| 512 |
-
if (profileDropdown.style.display === 'block') positionProfileDropdown();
|
| 513 |
-
});
|
| 514 |
-
|
| 515 |
-
window.addEventListener('scroll', () => {
|
| 516 |
-
if (profileDropdown.style.display === 'block') positionProfileDropdown();
|
| 517 |
-
}, true);
|
| 518 |
-
|
| 519 |
-
// Handle option selection
|
| 520 |
-
const optionItems = profileDropdown.querySelectorAll('.user-option-item');
|
| 521 |
-
optionItems.forEach(item => {
|
| 522 |
-
item.addEventListener('click', async (e) => {
|
| 523 |
-
e.stopPropagation();
|
| 524 |
-
const val = item.getAttribute('data-value');
|
| 525 |
-
const [userId, role, name] = val.split('|');
|
| 526 |
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
updateActiveUserUI(userId, role, name);
|
| 532 |
-
closeProfileDropdown();
|
| 533 |
|
| 534 |
-
if (
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
}
|
| 543 |
});
|
| 544 |
-
}
|
| 545 |
}
|
| 546 |
|
| 547 |
// Listen for hash change for routing
|
| 548 |
window.addEventListener('hashchange', handleNavigation);
|
| 549 |
|
| 550 |
-
//
|
| 551 |
-
await
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
// Ctrl + K functionality removed per user request
|
| 557 |
|
|
|
|
| 1 |
|
| 2 |
+
// Global fetch interceptor to inject simulated user context
|
| 3 |
// Global fetch interceptor to inject simulated user context
|
| 4 |
(function() {
|
| 5 |
const originalFetch = window.fetch;
|
| 6 |
window.fetch = async function(url, options = {}) {
|
| 7 |
+
let userId = '2';
|
| 8 |
+
let userRole = 'BD Rep';
|
| 9 |
+
let userName = 'bd rep1';
|
| 10 |
+
|
| 11 |
+
if (window.currentUser) {
|
| 12 |
+
userId = String(window.currentUser.id);
|
| 13 |
+
userRole = window.currentUser.role;
|
| 14 |
+
userName = window.currentUser.name;
|
| 15 |
+
} else {
|
| 16 |
+
userId = sessionStorage.getItem('prospectiq_user_id') || '2';
|
| 17 |
+
userRole = sessionStorage.getItem('prospectiq_user_role') || 'BD Rep';
|
| 18 |
+
userName = sessionStorage.getItem('prospectiq_user_name') || 'bd rep1';
|
| 19 |
+
}
|
| 20 |
|
| 21 |
options.headers = options.headers || {};
|
| 22 |
if (options.headers instanceof Headers) {
|
|
|
|
| 36 |
};
|
| 37 |
})();
|
| 38 |
|
| 39 |
+
// Global Authentication State
|
| 40 |
+
window.currentUser = null;
|
| 41 |
+
window.userPermissions = [];
|
| 42 |
+
|
| 43 |
+
async function checkAuthentication() {
|
| 44 |
+
showGlobalLoader('Verifying session...');
|
| 45 |
+
try {
|
| 46 |
+
const res = await fetch('/api/auth/me');
|
| 47 |
+
if (res.ok) {
|
| 48 |
+
const data = await res.json();
|
| 49 |
+
|
| 50 |
+
// Set viewMode default to portal on new user login session
|
| 51 |
+
if (!window.currentUser || window.currentUser.id !== data.user.id) {
|
| 52 |
+
sessionStorage.setItem('admin_view_mode', 'portal');
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
window.currentUser = data.user;
|
| 56 |
+
window.userPermissions = data.user.permissions;
|
| 57 |
+
|
| 58 |
+
// Sync to sessionStorage for backward compatibility across other script files
|
| 59 |
+
sessionStorage.setItem('prospectiq_user_id', String(data.user.id));
|
| 60 |
+
sessionStorage.setItem('prospectiq_user_role', data.user.role);
|
| 61 |
+
sessionStorage.setItem('prospectiq_user_name', data.user.name);
|
| 62 |
+
|
| 63 |
+
const authContainer = document.getElementById('auth-container');
|
| 64 |
+
if (authContainer) authContainer.classList.add('d-none');
|
| 65 |
+
const appLayout = document.querySelector('.app-layout');
|
| 66 |
+
if (appLayout) appLayout.classList.remove('blur-layout');
|
| 67 |
+
|
| 68 |
+
// Show sidebar and header when logged in
|
| 69 |
+
const sidebar = document.querySelector('.sidebar');
|
| 70 |
+
const header = document.querySelector('.top-command-header');
|
| 71 |
+
if (sidebar) sidebar.style.display = '';
|
| 72 |
+
if (header) header.style.display = '';
|
| 73 |
+
|
| 74 |
+
updateActiveUserUIFromSession();
|
| 75 |
+
renderConditionalNav();
|
| 76 |
+
hideGlobalLoader();
|
| 77 |
+
return true;
|
| 78 |
+
}
|
| 79 |
+
} catch (err) {
|
| 80 |
+
console.warn("User not authenticated:", err);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
sessionStorage.removeItem('prospectiq_user_id');
|
| 84 |
+
sessionStorage.removeItem('prospectiq_user_role');
|
| 85 |
+
sessionStorage.removeItem('prospectiq_user_name');
|
| 86 |
+
window.currentUser = null;
|
| 87 |
+
window.userPermissions = [];
|
| 88 |
+
showLoginScreen();
|
| 89 |
+
hideGlobalLoader();
|
| 90 |
+
return false;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function showLoginScreen() {
|
| 94 |
+
const authContainer = document.getElementById('auth-container');
|
| 95 |
+
if (authContainer) authContainer.classList.remove('d-none');
|
| 96 |
+
const appLayout = document.querySelector('.app-layout');
|
| 97 |
+
if (appLayout) appLayout.classList.add('blur-layout');
|
| 98 |
+
|
| 99 |
+
// Hide sidebar and header during login
|
| 100 |
+
const sidebar = document.querySelector('.sidebar');
|
| 101 |
+
const header = document.querySelector('.top-command-header');
|
| 102 |
+
if (sidebar) sidebar.style.display = 'none';
|
| 103 |
+
if (header) header.style.display = 'none';
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function updateActiveUserUIFromSession() {
|
| 107 |
+
if (!window.currentUser) return;
|
| 108 |
+
const nameEl = document.getElementById('user-profile-name');
|
| 109 |
+
const roleEl = document.getElementById('user-profile-role');
|
| 110 |
+
const avatarEl = document.getElementById('user-profile-avatar');
|
| 111 |
+
|
| 112 |
+
if (nameEl) nameEl.textContent = window.currentUser.name;
|
| 113 |
+
if (roleEl) roleEl.textContent = window.currentUser.role;
|
| 114 |
+
if (avatarEl) {
|
| 115 |
+
let initials = window.currentUser.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
| 116 |
+
avatarEl.textContent = initials;
|
| 117 |
+
avatarEl.className = 'user-avatar';
|
| 118 |
+
if (window.currentUser.role === 'Admin') {
|
| 119 |
+
avatarEl.classList.add('admin');
|
| 120 |
+
} else if (window.currentUser.role === 'BD Rep') {
|
| 121 |
+
avatarEl.classList.add('bd-rep');
|
| 122 |
+
} else {
|
| 123 |
+
avatarEl.classList.add('viewer');
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function renderConditionalNav() {
|
| 129 |
+
const dropdown = document.getElementById('user-profile-dropdown');
|
| 130 |
+
if (!dropdown) return;
|
| 131 |
+
|
| 132 |
+
// Clear any previous dynamic options to prevent duplicates
|
| 133 |
+
const oldLogout = dropdown.querySelector('.logout-option-item');
|
| 134 |
+
if (oldLogout) oldLogout.remove();
|
| 135 |
+
const oldAdmin = dropdown.querySelector('.admin-toggle-option-item');
|
| 136 |
+
if (oldAdmin) oldAdmin.remove();
|
| 137 |
+
|
| 138 |
+
// 1. If Admin, append workspace/portal toggle option with premium UI style
|
| 139 |
+
const isAdmin = window.userPermissions && window.userPermissions.includes('users:manage');
|
| 140 |
+
if (isAdmin) {
|
| 141 |
+
const viewMode = sessionStorage.getItem('admin_view_mode') || 'portal';
|
| 142 |
+
const adminDiv = document.createElement('div');
|
| 143 |
+
adminDiv.className = 'admin-toggle-option-item';
|
| 144 |
+
|
| 145 |
+
// Premium custom layout properties for high-contrast card look
|
| 146 |
+
adminDiv.style.display = 'flex';
|
| 147 |
+
adminDiv.style.alignItems = 'center';
|
| 148 |
+
adminDiv.style.gap = '10px';
|
| 149 |
+
adminDiv.style.padding = '8px 12px';
|
| 150 |
+
adminDiv.style.margin = '8px 8px 4px 8px';
|
| 151 |
+
adminDiv.style.borderRadius = '10px';
|
| 152 |
+
adminDiv.style.cursor = 'pointer';
|
| 153 |
+
adminDiv.style.transition = 'all 0.2s ease';
|
| 154 |
+
|
| 155 |
+
if (viewMode === 'workspace') {
|
| 156 |
+
// High-contrast slate gradient for Admin Console Toggle
|
| 157 |
+
adminDiv.style.background = 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)';
|
| 158 |
+
adminDiv.style.color = '#ffffff';
|
| 159 |
+
adminDiv.style.boxShadow = '0 2px 8px rgba(15, 23, 42, 0.15)';
|
| 160 |
+
adminDiv.style.border = 'none';
|
| 161 |
+
|
| 162 |
+
adminDiv.innerHTML = `
|
| 163 |
+
<div style="width: 28px; height: 28px; background: rgba(255,255,255,0.15) !important; color: white !important; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 1rem;"><i class="bi bi-shield-check"></i></div>
|
| 164 |
+
<div style="flex-grow: 1;">
|
| 165 |
+
<div style="font-weight: 600; color: #ffffff; line-height: 1.2; font-size: 0.8rem;">Admin Console</div>
|
| 166 |
+
<div style="font-size: 0.65rem; color: #cbd5e1; line-height: 1.2; margin-top: 1px;">Manage reps & roles</div>
|
| 167 |
+
</div>
|
| 168 |
+
<i class="bi bi-chevron-right" style="color: #94a3b8 !important; font-size: 0.75rem;"></i>
|
| 169 |
+
`;
|
| 170 |
+
|
| 171 |
+
adminDiv.addEventListener('mouseenter', () => {
|
| 172 |
+
adminDiv.style.background = 'linear-gradient(135deg, #334155 0%, #1e293b 100%)';
|
| 173 |
+
adminDiv.style.transform = 'translateY(-1px)';
|
| 174 |
+
});
|
| 175 |
+
adminDiv.addEventListener('mouseleave', () => {
|
| 176 |
+
adminDiv.style.background = 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)';
|
| 177 |
+
adminDiv.style.transform = 'translateY(0)';
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
adminDiv.addEventListener('click', (e) => {
|
| 181 |
+
e.stopPropagation();
|
| 182 |
+
sessionStorage.setItem('admin_view_mode', 'portal');
|
| 183 |
+
window.location.hash = '#/';
|
| 184 |
+
if (typeof handleNavigation === 'function') handleNavigation();
|
| 185 |
+
renderConditionalNav();
|
| 186 |
+
});
|
| 187 |
+
} else {
|
| 188 |
+
// Elegant green gradient for Sales Workspace Toggle
|
| 189 |
+
adminDiv.style.background = 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)';
|
| 190 |
+
adminDiv.style.color = '#14532d';
|
| 191 |
+
adminDiv.style.boxShadow = '0 2px 8px rgba(20, 83, 45, 0.08)';
|
| 192 |
+
adminDiv.style.border = '1px solid #bbf7d0';
|
| 193 |
+
|
| 194 |
+
adminDiv.innerHTML = `
|
| 195 |
+
<div style="width: 28px; height: 28px; background: #22c55e !important; color: white !important; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 1rem;"><i class="bi bi-briefcase"></i></div>
|
| 196 |
+
<div style="flex-grow: 1;">
|
| 197 |
+
<div style="font-weight: 600; color: #14532d; line-height: 1.2; font-size: 0.8rem;">Sales Workspace</div>
|
| 198 |
+
<div style="font-size: 0.65rem; color: #16a34a; line-height: 1.2; margin-top: 1px;">Access pipeline data</div>
|
| 199 |
+
</div>
|
| 200 |
+
<i class="bi bi-chevron-right" style="color: #16a34a !important; font-size: 0.75rem;"></i>
|
| 201 |
+
`;
|
| 202 |
+
|
| 203 |
+
adminDiv.addEventListener('mouseenter', () => {
|
| 204 |
+
adminDiv.style.background = 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)';
|
| 205 |
+
adminDiv.style.transform = 'translateY(-1px)';
|
| 206 |
+
});
|
| 207 |
+
adminDiv.addEventListener('mouseleave', () => {
|
| 208 |
+
adminDiv.style.background = 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)';
|
| 209 |
+
adminDiv.style.transform = 'translateY(0)';
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
adminDiv.addEventListener('click', (e) => {
|
| 213 |
+
e.stopPropagation();
|
| 214 |
+
sessionStorage.setItem('admin_view_mode', 'workspace');
|
| 215 |
+
window.location.hash = '#/';
|
| 216 |
+
if (typeof handleNavigation === 'function') handleNavigation();
|
| 217 |
+
renderConditionalNav();
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
dropdown.appendChild(adminDiv);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// 2. Append Logout option
|
| 224 |
+
const logoutDiv = document.createElement('div');
|
| 225 |
+
logoutDiv.className = 'user-option-item logout-option-item';
|
| 226 |
+
logoutDiv.style.borderTop = '1px solid rgba(0,0,0,0.06)';
|
| 227 |
+
logoutDiv.style.marginTop = '4px';
|
| 228 |
+
logoutDiv.style.paddingTop = '8px';
|
| 229 |
+
logoutDiv.innerHTML = `
|
| 230 |
+
<div class="option-avatar" style="background: #dc3545 !important; color: white !important; display: flex; align-items: center; justify-content: center;"><i class="bi bi-box-arrow-right"></i></div>
|
| 231 |
+
<div>
|
| 232 |
+
<div style="font-weight: 600; color: #dc3545; line-height: 1.2;">Sign Out</div>
|
| 233 |
+
<div style="font-size: 0.68rem; color: var(--text-secondary); line-height: 1.2;">Exit Session</div>
|
| 234 |
+
</div>
|
| 235 |
+
`;
|
| 236 |
+
logoutDiv.addEventListener('click', async (e) => {
|
| 237 |
+
e.stopPropagation();
|
| 238 |
+
showGlobalLoader('Logging out, please wait...');
|
| 239 |
+
await fetch('/api/auth/logout', { method: 'POST' });
|
| 240 |
+
window.location.reload();
|
| 241 |
+
});
|
| 242 |
+
dropdown.appendChild(logoutDiv);
|
| 243 |
+
|
| 244 |
+
// Remove sidebar toggle if present to avoid duplicate buttons
|
| 245 |
+
const oldToggle = document.getElementById('nav-admin-workspace-toggle');
|
| 246 |
+
if (oldToggle) oldToggle.remove();
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
function commandEscapeHtml(value) {
|
| 251 |
const div = document.createElement('div');
|
| 252 |
div.textContent = value == null ? '' : String(value);
|
|
|
|
| 550 |
|
| 551 |
// Check if collapsed state was saved in localStorage
|
| 552 |
const isCollapsed = localStorage.getItem('prospectiq-sidebar-collapsed') === 'true';
|
| 553 |
+
const sidebarIcon = document.getElementById('sidebar-toggle-icon');
|
| 554 |
if (isCollapsed) {
|
| 555 |
document.body.classList.add('sidebar-collapsed');
|
| 556 |
+
if (sidebarIcon) {
|
| 557 |
+
sidebarIcon.className = 'bi bi-chevron-right';
|
| 558 |
+
}
|
| 559 |
if (sidebarLogoText) sidebarLogoText.textContent = 'PIQ';
|
| 560 |
}
|
| 561 |
|
|
|
|
| 564 |
const willCollapse = !document.body.classList.contains('sidebar-collapsed');
|
| 565 |
document.body.classList.toggle('sidebar-collapsed', willCollapse);
|
| 566 |
localStorage.setItem('prospectiq-sidebar-collapsed', willCollapse);
|
| 567 |
+
if (sidebarIcon) {
|
| 568 |
+
sidebarIcon.className = willCollapse ? 'bi bi-chevron-right' : 'bi bi-chevron-left';
|
| 569 |
+
}
|
| 570 |
+
if (sidebarLogoText) {
|
| 571 |
+
sidebarLogoText.textContent = willCollapse ? 'PIQ' : 'ProspectIQ';
|
| 572 |
+
}
|
| 573 |
+
// Trigger window resize to adjust layout if needed
|
| 574 |
+
window.dispatchEvent(new Event('resize'));
|
| 575 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
}
|
| 577 |
|
| 578 |
const searchInput = document.getElementById('global-command-bar');
|
|
|
|
| 603 |
updatePlaceholder();
|
| 604 |
window.addEventListener('resize', updatePlaceholder);
|
| 605 |
|
| 606 |
+
// Dynamic Authentication Setup
|
| 607 |
+
setupAuthListeners();
|
| 608 |
+
|
| 609 |
+
// Wire User Profile Dropdown Toggle (Custom Profile Widget in Sidebar Footer)
|
| 610 |
const profileTrigger = document.getElementById('user-profile-trigger');
|
| 611 |
const profileDropdown = document.getElementById('user-profile-dropdown');
|
| 612 |
|
| 613 |
if (profileTrigger && profileDropdown) {
|
| 614 |
+
const resetProfileDropdownPosition = () => {
|
| 615 |
+
profileDropdown.classList.remove('is-floating');
|
| 616 |
+
profileDropdown.style.left = '';
|
| 617 |
+
profileDropdown.style.top = '';
|
| 618 |
+
profileDropdown.style.width = '';
|
| 619 |
+
};
|
| 620 |
+
|
| 621 |
+
const positionProfileDropdown = () => {
|
| 622 |
+
const collapsedDesktop = document.body.classList.contains('sidebar-collapsed') && window.innerWidth >= 992;
|
| 623 |
+
if (!collapsedDesktop) {
|
| 624 |
+
resetProfileDropdownPosition();
|
| 625 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
}
|
| 627 |
+
|
| 628 |
+
profileDropdown.classList.add('is-floating');
|
| 629 |
+
profileDropdown.style.width = '220px';
|
| 630 |
+
|
| 631 |
+
const triggerRect = profileTrigger.getBoundingClientRect();
|
| 632 |
+
const sidebarRect = document.querySelector('.sidebar')?.getBoundingClientRect();
|
| 633 |
+
const dropdownRect = profileDropdown.getBoundingClientRect();
|
| 634 |
+
const gap = 10;
|
| 635 |
+
const viewportPadding = 12;
|
| 636 |
+
const dropdownWidth = dropdownRect.width || 220;
|
| 637 |
+
const dropdownHeight = dropdownRect.height || 260;
|
| 638 |
+
const anchorLeft = Math.max(
|
| 639 |
+
triggerRect.right + gap,
|
| 640 |
+
(sidebarRect?.right || triggerRect.right) + gap
|
| 641 |
+
);
|
| 642 |
+
|
| 643 |
+
const left = Math.min(
|
| 644 |
+
anchorLeft,
|
| 645 |
+
window.innerWidth - dropdownWidth - viewportPadding
|
| 646 |
+
);
|
| 647 |
+
const top = Math.min(
|
| 648 |
+
Math.max(viewportPadding, triggerRect.bottom - dropdownHeight),
|
| 649 |
+
window.innerHeight - dropdownHeight - viewportPadding
|
| 650 |
+
);
|
| 651 |
+
|
| 652 |
+
profileDropdown.style.left = `${Math.max(viewportPadding, left)}px`;
|
| 653 |
+
profileDropdown.style.top = `${Math.max(viewportPadding, top)}px`;
|
| 654 |
+
};
|
| 655 |
+
|
| 656 |
+
const openProfileDropdown = () => {
|
| 657 |
+
profileDropdown.style.display = 'block';
|
| 658 |
+
profileTrigger.setAttribute('aria-expanded', 'true');
|
| 659 |
+
positionProfileDropdown();
|
| 660 |
+
};
|
| 661 |
+
|
| 662 |
+
const closeProfileDropdown = () => {
|
| 663 |
+
profileDropdown.style.display = 'none';
|
| 664 |
+
profileTrigger.setAttribute('aria-expanded', 'false');
|
| 665 |
+
resetProfileDropdownPosition();
|
| 666 |
+
};
|
| 667 |
+
|
| 668 |
+
// Toggle dropdown on click
|
| 669 |
+
profileTrigger.addEventListener('click', (e) => {
|
| 670 |
+
e.stopPropagation();
|
| 671 |
+
const isOpen = profileDropdown.style.display === 'block';
|
| 672 |
+
if (isOpen) closeProfileDropdown();
|
| 673 |
+
else openProfileDropdown();
|
| 674 |
+
});
|
| 675 |
+
|
| 676 |
+
// Close dropdown when clicking anywhere else
|
| 677 |
+
document.addEventListener('click', () => {
|
| 678 |
+
closeProfileDropdown();
|
| 679 |
+
});
|
| 680 |
+
|
| 681 |
+
window.addEventListener('resize', () => {
|
| 682 |
+
if (profileDropdown.style.display === 'block') positionProfileDropdown();
|
| 683 |
+
});
|
| 684 |
+
|
| 685 |
+
window.addEventListener('scroll', () => {
|
| 686 |
+
if (profileDropdown.style.display === 'block') positionProfileDropdown();
|
| 687 |
+
}, true);
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
// Wire setupAuthListeners helper
|
| 691 |
+
function setupAuthListeners() {
|
| 692 |
+
const tabSignin = document.getElementById('auth-tab-signin');
|
| 693 |
+
const tabSignup = document.getElementById('auth-tab-signup');
|
| 694 |
+
const groupOrg = document.getElementById('auth-group-org');
|
| 695 |
+
const groupName = document.getElementById('auth-group-name');
|
| 696 |
+
const subtitle = document.getElementById('auth-subtitle');
|
| 697 |
+
const submitBtn = document.getElementById('auth-submit-btn');
|
| 698 |
+
const authForm = document.getElementById('auth-form');
|
| 699 |
+
const errorAlert = document.getElementById('auth-error-alert');
|
| 700 |
+
|
| 701 |
+
let isSignupMode = false;
|
| 702 |
+
|
| 703 |
+
if (tabSignin && tabSignup) {
|
| 704 |
+
tabSignin.addEventListener('click', () => {
|
| 705 |
+
isSignupMode = false;
|
| 706 |
+
tabSignin.classList.add('active');
|
| 707 |
+
tabSignup.classList.remove('active');
|
| 708 |
+
if (groupOrg) groupOrg.classList.add('d-none');
|
| 709 |
+
if (groupName) groupName.classList.add('d-none');
|
| 710 |
+
const orgInput = document.getElementById('auth-org-name');
|
| 711 |
+
const userInput = document.getElementById('auth-user-name');
|
| 712 |
+
if (orgInput) orgInput.required = false;
|
| 713 |
+
if (userInput) userInput.required = false;
|
| 714 |
+
if (subtitle) subtitle.textContent = 'Sign in to manage your pipeline';
|
| 715 |
+
if (submitBtn) submitBtn.textContent = 'Sign In';
|
| 716 |
+
if (errorAlert) errorAlert.classList.add('d-none');
|
| 717 |
+
});
|
| 718 |
|
| 719 |
+
tabSignup.addEventListener('click', () => {
|
| 720 |
+
isSignupMode = true;
|
| 721 |
+
tabSignup.classList.add('active');
|
| 722 |
+
tabSignin.classList.remove('active');
|
| 723 |
+
if (groupOrg) groupOrg.classList.remove('d-none');
|
| 724 |
+
if (groupName) groupName.classList.remove('d-none');
|
| 725 |
+
const orgInput = document.getElementById('auth-org-name');
|
| 726 |
+
const userInput = document.getElementById('auth-user-name');
|
| 727 |
+
if (orgInput) orgInput.required = true;
|
| 728 |
+
if (userInput) userInput.required = true;
|
| 729 |
+
if (subtitle) subtitle.textContent = 'Register a new organization and admin account';
|
| 730 |
+
if (submitBtn) submitBtn.textContent = 'Register Organization';
|
| 731 |
+
if (errorAlert) errorAlert.classList.add('d-none');
|
| 732 |
});
|
| 733 |
+
}
|
| 734 |
|
| 735 |
+
if (authForm) {
|
| 736 |
+
authForm.addEventListener('submit', async (e) => {
|
| 737 |
+
e.preventDefault();
|
| 738 |
+
if (errorAlert) errorAlert.classList.add('d-none');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
|
| 740 |
+
const emailInput = document.getElementById('auth-email');
|
| 741 |
+
const passwordInput = document.getElementById('auth-password');
|
| 742 |
+
const email = emailInput ? emailInput.value : '';
|
| 743 |
+
const password = passwordInput ? passwordInput.value : '';
|
|
|
|
|
|
|
| 744 |
|
| 745 |
+
if (isSignupMode) {
|
| 746 |
+
const orgInput = document.getElementById('auth-org-name');
|
| 747 |
+
const nameInput = document.getElementById('auth-user-name');
|
| 748 |
+
const org_name = orgInput ? orgInput.value : '';
|
| 749 |
+
const name = nameInput ? nameInput.value : '';
|
| 750 |
+
|
| 751 |
+
showGlobalLoader('Creating organization & admin account...');
|
| 752 |
+
try {
|
| 753 |
+
const res = await fetch('/api/auth/register', {
|
| 754 |
+
method: 'POST',
|
| 755 |
+
headers: { 'Content-Type': 'application/json' },
|
| 756 |
+
body: JSON.stringify({ org_name, name, email, password })
|
| 757 |
+
});
|
| 758 |
+
const data = await res.json();
|
| 759 |
+
if (res.ok) {
|
| 760 |
+
showGlobalLoader('Authenticating details...');
|
| 761 |
+
const loginRes = await fetch('/api/auth/login', {
|
| 762 |
+
method: 'POST',
|
| 763 |
+
headers: { 'Content-Type': 'application/json' },
|
| 764 |
+
body: JSON.stringify({ email, password })
|
| 765 |
+
});
|
| 766 |
+
if (loginRes.ok) {
|
| 767 |
+
await checkAuthentication();
|
| 768 |
+
await loadDataFromDb();
|
| 769 |
+
handleNavigation();
|
| 770 |
+
} else {
|
| 771 |
+
hideGlobalLoader();
|
| 772 |
+
if (tabSignin) tabSignin.click();
|
| 773 |
+
const emailInputSign = document.getElementById('auth-email');
|
| 774 |
+
const passwordInputSign = document.getElementById('auth-password');
|
| 775 |
+
if (emailInputSign) emailInputSign.value = email;
|
| 776 |
+
if (passwordInputSign) passwordInputSign.value = password;
|
| 777 |
+
if (errorAlert) {
|
| 778 |
+
errorAlert.textContent = 'Registration succeeded. Please sign in.';
|
| 779 |
+
errorAlert.classList.remove('d-none');
|
| 780 |
+
}
|
| 781 |
+
}
|
| 782 |
+
} else {
|
| 783 |
+
hideGlobalLoader();
|
| 784 |
+
if (errorAlert) {
|
| 785 |
+
errorAlert.textContent = data.error || 'Registration failed';
|
| 786 |
+
errorAlert.classList.remove('d-none');
|
| 787 |
+
}
|
| 788 |
+
}
|
| 789 |
+
} catch (err) {
|
| 790 |
+
hideGlobalLoader();
|
| 791 |
+
if (errorAlert) {
|
| 792 |
+
errorAlert.textContent = 'Network error connecting to server';
|
| 793 |
+
errorAlert.classList.remove('d-none');
|
| 794 |
+
}
|
| 795 |
+
}
|
| 796 |
+
} else {
|
| 797 |
+
showGlobalLoader('Authenticating credentials...');
|
| 798 |
+
try {
|
| 799 |
+
const res = await fetch('/api/auth/login', {
|
| 800 |
+
method: 'POST',
|
| 801 |
+
headers: { 'Content-Type': 'application/json' },
|
| 802 |
+
body: JSON.stringify({ email, password })
|
| 803 |
+
});
|
| 804 |
+
const data = await res.json();
|
| 805 |
+
if (res.ok) {
|
| 806 |
+
await checkAuthentication();
|
| 807 |
+
await loadDataFromDb();
|
| 808 |
+
handleNavigation();
|
| 809 |
+
} else {
|
| 810 |
+
hideGlobalLoader();
|
| 811 |
+
if (errorAlert) {
|
| 812 |
+
errorAlert.textContent = data.error || 'Invalid credentials';
|
| 813 |
+
errorAlert.classList.remove('d-none');
|
| 814 |
+
}
|
| 815 |
+
}
|
| 816 |
+
} catch (err) {
|
| 817 |
+
hideGlobalLoader();
|
| 818 |
+
if (errorAlert) {
|
| 819 |
+
errorAlert.textContent = 'Network error connecting to server';
|
| 820 |
+
errorAlert.classList.remove('d-none');
|
| 821 |
+
}
|
| 822 |
+
}
|
| 823 |
}
|
| 824 |
});
|
| 825 |
+
}
|
| 826 |
}
|
| 827 |
|
| 828 |
// Listen for hash change for routing
|
| 829 |
window.addEventListener('hashchange', handleNavigation);
|
| 830 |
|
| 831 |
+
// Check if user is authenticated before loading initial data and executing route
|
| 832 |
+
const isAuthenticated = await checkAuthentication();
|
| 833 |
+
if (isAuthenticated) {
|
| 834 |
+
// Load all initial data in one shot via bootstrap endpoint
|
| 835 |
+
await loadDataFromDb();
|
| 836 |
+
// Initial load check
|
| 837 |
+
handleNavigation();
|
| 838 |
+
}
|
| 839 |
|
| 840 |
// Ctrl + K functionality removed per user request
|
| 841 |
|
static/js/components/tasks.js
CHANGED
|
@@ -11,13 +11,10 @@ function initTaskManagement() {
|
|
| 11 |
window.calendarCurrentDate = new Date();
|
| 12 |
}
|
| 13 |
|
| 14 |
-
//
|
| 15 |
let tasks = JSON.parse(localStorage.getItem('prospectiq-tasks') || '[]');
|
| 16 |
-
if (tasks.length ===
|
| 17 |
-
tasks = [
|
| 18 |
-
{ id: 1, title: 'Upload primary contact data sheets', completed: true, date: formatDate(new Date()), time: '09:00', notes: 'Initial import step' },
|
| 19 |
-
{ id: 2, title: 'Read the learning playbook on MapleMPSS', completed: false, date: formatDate(new Date()), time: '14:00', notes: 'Read carefully' }
|
| 20 |
-
];
|
| 21 |
localStorage.setItem('prospectiq-tasks', JSON.stringify(tasks));
|
| 22 |
}
|
| 23 |
|
|
|
|
| 11 |
window.calendarCurrentDate = new Date();
|
| 12 |
}
|
| 13 |
|
| 14 |
+
// Ensure no mock tasks are loaded in local storage
|
| 15 |
let tasks = JSON.parse(localStorage.getItem('prospectiq-tasks') || '[]');
|
| 16 |
+
if (tasks.length > 0 && tasks.some(t => t.title === 'Upload primary contact data sheets' || t.title === 'Read the learning playbook on MapleMPSS')) {
|
| 17 |
+
tasks = [];
|
|
|
|
|
|
|
|
|
|
| 18 |
localStorage.setItem('prospectiq-tasks', JSON.stringify(tasks));
|
| 19 |
}
|
| 20 |
|
static/js/router.js
CHANGED
|
@@ -148,7 +148,15 @@ function handleNavigation() {
|
|
| 148 |
}
|
| 149 |
|
| 150 |
if (hash === '#/' || hash === '') {
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
} else if (/^#\/companies\/\d+$/.test(hash)) {
|
| 153 |
const companyId = parseInt(hash.split('/').pop(), 10);
|
| 154 |
renderCompanyProfilePage(pageContent, companyId);
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
if (hash === '#/' || hash === '') {
|
| 151 |
+
const isAdmin = window.userPermissions && window.userPermissions.includes('users:manage');
|
| 152 |
+
const viewMode = sessionStorage.getItem('admin_view_mode') || 'portal';
|
| 153 |
+
if (isAdmin && viewMode === 'portal') {
|
| 154 |
+
renderAdminPortal(pageContent);
|
| 155 |
+
} else {
|
| 156 |
+
renderHome(pageContent);
|
| 157 |
+
}
|
| 158 |
+
} else if (hash === '#/admin-portal') {
|
| 159 |
+
renderAdminPortal(pageContent);
|
| 160 |
} else if (/^#\/companies\/\d+$/.test(hash)) {
|
| 161 |
const companyId = parseInt(hash.split('/').pop(), 10);
|
| 162 |
renderCompanyProfilePage(pageContent, companyId);
|
static/js/utils.js
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
function escapeHtml(str) {
|
| 2 |
if (str === null || str === undefined) return '';
|
| 3 |
return String(str)
|
|
|
|
| 1 |
+
function showGlobalLoader(message = 'Loading...') {
|
| 2 |
+
let loader = document.getElementById('global-app-loader');
|
| 3 |
+
if (!loader) {
|
| 4 |
+
loader = document.createElement('div');
|
| 5 |
+
loader.id = 'global-app-loader';
|
| 6 |
+
loader.style.position = 'fixed';
|
| 7 |
+
loader.style.top = '0';
|
| 8 |
+
loader.style.left = '0';
|
| 9 |
+
loader.style.width = '100vw';
|
| 10 |
+
loader.style.height = '100vh';
|
| 11 |
+
loader.style.background = 'rgba(255, 255, 255, 0.4)';
|
| 12 |
+
loader.style.backdropFilter = 'blur(10px)';
|
| 13 |
+
loader.style.display = 'flex';
|
| 14 |
+
loader.style.alignItems = 'center';
|
| 15 |
+
loader.style.justifyContent = 'center';
|
| 16 |
+
loader.style.zIndex = '99999';
|
| 17 |
+
loader.style.transition = 'opacity 0.2s ease-in-out';
|
| 18 |
+
|
| 19 |
+
loader.innerHTML = `
|
| 20 |
+
<div class="card p-4 border shadow-lg" style="width: 320px; border-radius: 16px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(16px); border: 1px solid rgba(0,0,0,0.06) !important;">
|
| 21 |
+
<div class="d-flex flex-column align-items-center text-center gap-3">
|
| 22 |
+
<div class="spinner-border text-dark" role="status" style="width: 2.4rem; height: 2.4rem; border-width: 0.2em;">
|
| 23 |
+
<span class="visually-hidden">Loading...</span>
|
| 24 |
+
</div>
|
| 25 |
+
<span class="fw-bold text-dark" id="global-app-loader-text" style="font-size: 0.9rem; letter-spacing: 0.5px; font-family: sans-serif;">${message}</span>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
`;
|
| 29 |
+
document.body.appendChild(loader);
|
| 30 |
+
} else {
|
| 31 |
+
const text = document.getElementById('global-app-loader-text');
|
| 32 |
+
if (text) text.textContent = message;
|
| 33 |
+
loader.style.display = 'flex';
|
| 34 |
+
loader.style.opacity = '1';
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function hideGlobalLoader() {
|
| 39 |
+
const loader = document.getElementById('global-app-loader');
|
| 40 |
+
if (loader) {
|
| 41 |
+
loader.style.opacity = '0';
|
| 42 |
+
setTimeout(() => {
|
| 43 |
+
loader.style.display = 'none';
|
| 44 |
+
}, 200);
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
function escapeHtml(str) {
|
| 49 |
if (str === null || str === undefined) return '';
|
| 50 |
return String(str)
|
static/js/views/mockups.js
CHANGED
|
@@ -78,6 +78,14 @@ function renderSettingsPage(container) {
|
|
| 78 |
|
| 79 |
initBaseCompanyContextSettings();
|
| 80 |
initCommandMemorySettings();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
async function loadBaseCompanyContext() {
|
|
@@ -257,3 +265,415 @@ function initCommandMemorySettings() {
|
|
| 257 |
}
|
| 258 |
loadSettingsCommandMemory();
|
| 259 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
initBaseCompanyContextSettings();
|
| 80 |
initCommandMemorySettings();
|
| 81 |
+
|
| 82 |
+
if (window.userPermissions && window.userPermissions.includes('users:manage')) {
|
| 83 |
+
const workspaceDiv = container.querySelector('.settings-workspace');
|
| 84 |
+
if (workspaceDiv) {
|
| 85 |
+
appendAdminConsole(workspaceDiv);
|
| 86 |
+
initSettingsAdminConsole();
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
}
|
| 90 |
|
| 91 |
async function loadBaseCompanyContext() {
|
|
|
|
| 265 |
}
|
| 266 |
loadSettingsCommandMemory();
|
| 267 |
}
|
| 268 |
+
|
| 269 |
+
function appendAdminConsole(workspaceDiv) {
|
| 270 |
+
const adminDiv = document.createElement('div');
|
| 271 |
+
adminDiv.className = 'section-card mt-4';
|
| 272 |
+
adminDiv.id = 'org-admin-card';
|
| 273 |
+
adminDiv.innerHTML = `
|
| 274 |
+
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3 pb-3 border-bottom">
|
| 275 |
+
<div>
|
| 276 |
+
<h3 class="section-card-title m-0 border-0 p-0">Organization & Team Access Matrix</h3>
|
| 277 |
+
<p class="text-muted small mb-0 mt-1">Invite team members, define roles, and toggle granular permissions.</p>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<!-- Tab headers -->
|
| 282 |
+
<ul class="nav nav-tabs mb-3" id="adminConsoleTabs" role="tablist">
|
| 283 |
+
<li class="nav-item" role="presentation">
|
| 284 |
+
<button class="nav-link active btn-sm" id="users-tab" data-bs-toggle="tab" data-bs-target="#users-tab-pane" type="button" role="tab" style="padding: 6px 16px;">Team Members</button>
|
| 285 |
+
</li>
|
| 286 |
+
<li class="nav-item" role="presentation">
|
| 287 |
+
<button class="nav-link btn-sm" id="roles-tab" data-bs-toggle="tab" data-bs-target="#roles-tab-pane" type="button" role="tab" style="padding: 6px 16px;">Roles & Permissions</button>
|
| 288 |
+
</li>
|
| 289 |
+
</ul>
|
| 290 |
+
|
| 291 |
+
<div class="tab-content" id="adminConsoleTabsContent">
|
| 292 |
+
<!-- Team Members Tab -->
|
| 293 |
+
<div class="tab-pane fade show active" id="users-tab-pane" role="tabpanel">
|
| 294 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 295 |
+
<h6 class="fw-bold m-0" style="font-size: 0.95rem;">Organization Members</h6>
|
| 296 |
+
<button class="btn btn-dark-custom btn-sm rounded-pill px-3" id="btn-show-add-user-form" type="button">+ Create User</button>
|
| 297 |
+
</div>
|
| 298 |
+
|
| 299 |
+
<!-- Add User Form (hidden by default) -->
|
| 300 |
+
<form id="add-user-form" class="border p-4 rounded-4 mb-4 d-none bg-light-subtle">
|
| 301 |
+
<h6 class="fw-bold mb-3" style="font-size: 0.9rem;">Add New Team Member</h6>
|
| 302 |
+
<div class="row g-3">
|
| 303 |
+
<div class="col-md-3">
|
| 304 |
+
<label class="form-label small fw-semibold">Full Name</label>
|
| 305 |
+
<input type="text" class="form-control form-control-sm shadow-none" id="new-user-name" placeholder="John Doe" required>
|
| 306 |
+
</div>
|
| 307 |
+
<div class="col-md-3">
|
| 308 |
+
<label class="form-label small fw-semibold">Email Address</label>
|
| 309 |
+
<input type="email" class="form-control form-control-sm shadow-none" id="new-user-email" placeholder="john@company.com" required>
|
| 310 |
+
</div>
|
| 311 |
+
<div class="col-md-3">
|
| 312 |
+
<label class="form-label small fw-semibold">Password</label>
|
| 313 |
+
<input type="password" class="form-control form-control-sm shadow-none" id="new-user-password" placeholder="••••••••" required>
|
| 314 |
+
</div>
|
| 315 |
+
<div class="col-md-3">
|
| 316 |
+
<label class="form-label small fw-semibold">Role</label>
|
| 317 |
+
<select class="form-select form-select-sm shadow-none" id="new-user-role-select" required>
|
| 318 |
+
<!-- Dynamically loaded roles -->
|
| 319 |
+
</select>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
<div id="add-user-error-alert" class="alert alert-danger btn-sm p-2 mt-3 d-none" style="font-size: 0.8rem;"></div>
|
| 323 |
+
<div class="d-flex gap-2 mt-3 justify-content-end">
|
| 324 |
+
<button class="btn btn-outline-secondary btn-sm rounded-pill px-3" type="button" id="btn-cancel-add-user">Cancel</button>
|
| 325 |
+
<button class="btn btn-dark-custom btn-sm rounded-pill px-4" type="submit">Create User</button>
|
| 326 |
+
</div>
|
| 327 |
+
</form>
|
| 328 |
+
|
| 329 |
+
<div class="table-responsive">
|
| 330 |
+
<table class="table table-hover align-middle table-sm border" style="font-size: 0.85rem; border-radius: 8px; overflow: hidden;">
|
| 331 |
+
<thead class="table-light">
|
| 332 |
+
<tr>
|
| 333 |
+
<th style="padding: 10px 12px;">Name</th>
|
| 334 |
+
<th style="padding: 10px 12px;">Email</th>
|
| 335 |
+
<th style="padding: 10px 12px;">Role</th>
|
| 336 |
+
<th style="padding: 10px 12px;">Status</th>
|
| 337 |
+
</tr>
|
| 338 |
+
</thead>
|
| 339 |
+
<tbody id="admin-users-table-body">
|
| 340 |
+
<tr><td colspan="4" class="text-center text-muted py-3">Loading team members...</td></tr>
|
| 341 |
+
</tbody>
|
| 342 |
+
</table>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<!-- Roles & Permissions Tab -->
|
| 347 |
+
<div class="tab-pane fade" id="roles-tab-pane" role="tabpanel">
|
| 348 |
+
<div class="row g-4">
|
| 349 |
+
<div class="col-md-4 border-end">
|
| 350 |
+
<h6 class="fw-bold mb-3" style="font-size: 0.95rem;">Defined Roles</h6>
|
| 351 |
+
<div class="list-group" id="admin-roles-list-group" style="font-size: 0.85rem; border-radius: 8px; overflow: hidden;">
|
| 352 |
+
<!-- Dynamic list of roles -->
|
| 353 |
+
</div>
|
| 354 |
+
<button class="btn btn-outline-dark btn-sm mt-3 w-100 rounded-pill" type="button" id="btn-show-create-role-modal">+ Create Custom Role</button>
|
| 355 |
+
|
| 356 |
+
<!-- Create Role Inline Form (hidden by default) -->
|
| 357 |
+
<form id="create-role-form" class="border p-3 rounded-4 mt-3 bg-light-subtle d-none">
|
| 358 |
+
<h6 class="fw-bold mb-2 small">New Custom Role</h6>
|
| 359 |
+
<input type="text" class="form-control form-control-sm mb-2 shadow-none" id="new-role-name" placeholder="Role Name (e.g. Senior Sales)" required>
|
| 360 |
+
<input type="text" class="form-control form-control-sm mb-2 shadow-none" id="new-role-desc" placeholder="Role Description">
|
| 361 |
+
<div id="create-role-error-alert" class="alert alert-danger btn-sm p-2 d-none" style="font-size: 0.75rem;"></div>
|
| 362 |
+
<div class="d-flex gap-2 justify-content-end mt-2">
|
| 363 |
+
<button class="btn btn-outline-secondary btn-sm p-1 px-2" type="button" id="btn-cancel-create-role" style="font-size: 0.75rem;">Cancel</button>
|
| 364 |
+
<button class="btn btn-dark-custom btn-sm p-1 px-3" type="submit" style="font-size: 0.75rem;">Save Role</button>
|
| 365 |
+
</div>
|
| 366 |
+
</form>
|
| 367 |
+
</div>
|
| 368 |
+
<div class="col-md-8">
|
| 369 |
+
<div class="border rounded-4 p-4 bg-light-subtle">
|
| 370 |
+
<h6 class="fw-bold mb-3" id="selected-role-name-display" style="font-size: 0.95rem;">Select a role to configure permissions</h6>
|
| 371 |
+
<form id="update-role-permissions-form">
|
| 372 |
+
<input type="hidden" id="selected-role-id-input">
|
| 373 |
+
<div class="d-flex flex-column gap-3" id="permissions-checkboxes-container" style="font-size: 0.88rem;">
|
| 374 |
+
<!-- Dynamic checkboxes for permissions -->
|
| 375 |
+
</div>
|
| 376 |
+
<div id="permissions-update-success-alert" class="alert alert-success btn-sm p-2 mt-3 d-none" style="font-size: 0.8rem;">Configuration saved successfully!</div>
|
| 377 |
+
<div id="permissions-update-error-alert" class="alert alert-danger btn-sm p-2 mt-3 d-none" style="font-size: 0.8rem;"></div>
|
| 378 |
+
<button class="btn btn-dark-custom btn-sm rounded-pill mt-3 px-4 d-none" type="submit" id="btn-save-role-permissions">Save Permissions Configuration</button>
|
| 379 |
+
</form>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
`;
|
| 386 |
+
workspaceDiv.appendChild(adminDiv);
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
async function initSettingsAdminConsole() {
|
| 390 |
+
const tableBody = document.getElementById('admin-users-table-body');
|
| 391 |
+
const rolesListGroup = document.getElementById('admin-roles-list-group');
|
| 392 |
+
const userRoleSelect = document.getElementById('new-user-role-select');
|
| 393 |
+
const addForm = document.getElementById('add-user-form');
|
| 394 |
+
const btnShowAddForm = document.getElementById('btn-show-add-user-form');
|
| 395 |
+
const btnCancelAddUser = document.getElementById('btn-cancel-add-user');
|
| 396 |
+
const createRoleForm = document.getElementById('create-role-form');
|
| 397 |
+
const btnShowCreateRole = document.getElementById('btn-show-create-role-modal');
|
| 398 |
+
const btnCancelCreateRole = document.getElementById('btn-cancel-create-role');
|
| 399 |
+
|
| 400 |
+
// Global lists
|
| 401 |
+
let allRoles = [];
|
| 402 |
+
let allPermissions = [];
|
| 403 |
+
|
| 404 |
+
// 1. Toggle add user form
|
| 405 |
+
if (btnShowAddForm && addForm) {
|
| 406 |
+
btnShowAddForm.addEventListener('click', () => {
|
| 407 |
+
addForm.classList.remove('d-none');
|
| 408 |
+
btnShowAddForm.classList.add('d-none');
|
| 409 |
+
});
|
| 410 |
+
}
|
| 411 |
+
if (btnCancelAddUser && addForm && btnShowAddForm) {
|
| 412 |
+
btnCancelAddUser.addEventListener('click', () => {
|
| 413 |
+
addForm.classList.add('d-none');
|
| 414 |
+
addForm.reset();
|
| 415 |
+
btnShowAddForm.classList.remove('d-none');
|
| 416 |
+
document.getElementById('add-user-error-alert').classList.add('d-none');
|
| 417 |
+
});
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// 2. Toggle create role form
|
| 421 |
+
if (btnShowCreateRole && createRoleForm) {
|
| 422 |
+
btnShowCreateRole.addEventListener('click', () => {
|
| 423 |
+
createRoleForm.classList.remove('d-none');
|
| 424 |
+
btnShowCreateRole.classList.add('d-none');
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
if (btnCancelCreateRole && createRoleForm && btnShowCreateRole) {
|
| 428 |
+
btnCancelCreateRole.addEventListener('click', () => {
|
| 429 |
+
createRoleForm.classList.add('d-none');
|
| 430 |
+
createRoleForm.reset();
|
| 431 |
+
btnShowCreateRole.classList.remove('d-none');
|
| 432 |
+
document.getElementById('create-role-error-alert').classList.add('d-none');
|
| 433 |
+
});
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
// Helper: Load Roles & Permissions lists
|
| 437 |
+
async function loadRolesAndUsers() {
|
| 438 |
+
try {
|
| 439 |
+
// Load Permissions
|
| 440 |
+
const permRes = await fetch('/api/auth/permissions');
|
| 441 |
+
if (permRes.ok) {
|
| 442 |
+
allPermissions = await permRes.json();
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// Load Roles
|
| 446 |
+
const rolesRes = await fetch('/api/auth/roles');
|
| 447 |
+
if (rolesRes.ok) {
|
| 448 |
+
allRoles = await rolesRes.json();
|
| 449 |
+
|
| 450 |
+
// Populate New User Role Select
|
| 451 |
+
if (userRoleSelect) {
|
| 452 |
+
userRoleSelect.innerHTML = allRoles.map(r => `
|
| 453 |
+
<option value="${r.id}">${r.name}</option>
|
| 454 |
+
`).join('');
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// Populate Roles List Group
|
| 458 |
+
if (rolesListGroup) {
|
| 459 |
+
rolesListGroup.innerHTML = allRoles.map((r, i) => `
|
| 460 |
+
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5" data-role-id="${r.id}" style="font-weight: 500;">
|
| 461 |
+
<span>${escapeHtml(r.name)}</span>
|
| 462 |
+
<span class="badge bg-secondary-subtle text-secondary-emphasis rounded-pill" style="font-size: 0.72rem;">${r.permissions.length} perms</span>
|
| 463 |
+
</button>
|
| 464 |
+
`).join('');
|
| 465 |
+
|
| 466 |
+
// Bind clicks to roles
|
| 467 |
+
rolesListGroup.querySelectorAll('[data-role-id]').forEach(btn => {
|
| 468 |
+
btn.addEventListener('click', () => selectRoleForPermissions(parseInt(btn.dataset.roleId, 10)));
|
| 469 |
+
});
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// Load Users
|
| 474 |
+
const usersRes = await fetch('/api/auth/users');
|
| 475 |
+
if (usersRes.ok && tableBody) {
|
| 476 |
+
const usersList = await usersRes.json();
|
| 477 |
+
tableBody.innerHTML = usersList.map(u => `
|
| 478 |
+
<tr>
|
| 479 |
+
<td style="padding: 10px 12px; font-weight: 500;">${escapeHtml(u.name)}</td>
|
| 480 |
+
<td style="padding: 10px 12px; color: var(--grey-text);">${escapeHtml(u.email)}</td>
|
| 481 |
+
<td style="padding: 10px 12px;"><span class="badge bg-dark-subtle text-dark border px-2 py-1 rounded-pill">${escapeHtml(u.role_name)}</span></td>
|
| 482 |
+
<td style="padding: 10px 12px;"><span class="badge bg-success-subtle text-success px-2 py-1 rounded-pill">${u.is_active === 1 ? 'Active' : 'Inactive'}</span></td>
|
| 483 |
+
</tr>
|
| 484 |
+
`).join('');
|
| 485 |
+
}
|
| 486 |
+
} catch (err) {
|
| 487 |
+
console.error('Failed to load admin console data:', err);
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
// Select Role & Render Permissions checkboxes
|
| 492 |
+
function selectRoleForPermissions(roleId) {
|
| 493 |
+
// Toggle active list item
|
| 494 |
+
rolesListGroup.querySelectorAll('[data-role-id]').forEach(btn => {
|
| 495 |
+
if (parseInt(btn.dataset.roleId, 10) === roleId) {
|
| 496 |
+
btn.classList.add('active');
|
| 497 |
+
} else {
|
| 498 |
+
btn.classList.remove('active');
|
| 499 |
+
}
|
| 500 |
+
});
|
| 501 |
+
|
| 502 |
+
const role = allRoles.find(r => r.id === roleId);
|
| 503 |
+
if (!role) return;
|
| 504 |
+
|
| 505 |
+
document.getElementById('selected-role-name-display').textContent = `Granular Permissions for Role: ${role.name}`;
|
| 506 |
+
document.getElementById('selected-role-id-input').value = role.id;
|
| 507 |
+
|
| 508 |
+
// Hide success alerts
|
| 509 |
+
document.getElementById('permissions-update-success-alert').classList.add('d-none');
|
| 510 |
+
document.getElementById('permissions-update-error-alert').classList.add('d-none');
|
| 511 |
+
|
| 512 |
+
const container = document.getElementById('permissions-checkboxes-container');
|
| 513 |
+
const saveBtn = document.getElementById('btn-save-role-permissions');
|
| 514 |
+
|
| 515 |
+
if (container) {
|
| 516 |
+
if (role.name === 'Admin') {
|
| 517 |
+
container.innerHTML = `
|
| 518 |
+
<div class="alert alert-info py-2" style="font-size: 0.82rem;">
|
| 519 |
+
<i class="bi bi-info-circle me-1"></i> Core Admin permissions cannot be modified to prevent system administrative lockouts.
|
| 520 |
+
</div>
|
| 521 |
+
` + allPermissions.map(p => `
|
| 522 |
+
<div class="form-check">
|
| 523 |
+
<input class="form-check-input" type="checkbox" checked disabled>
|
| 524 |
+
<label class="form-check-label text-muted">
|
| 525 |
+
<strong>${escapeHtml(p.code)}</strong> — <span class="small">${escapeHtml(p.description)}</span>
|
| 526 |
+
</label>
|
| 527 |
+
</div>
|
| 528 |
+
`).join('');
|
| 529 |
+
if (saveBtn) saveBtn.classList.add('d-none');
|
| 530 |
+
} else {
|
| 531 |
+
container.innerHTML = allPermissions.map(p => {
|
| 532 |
+
const isChecked = role.permissions.includes(p.code);
|
| 533 |
+
return `
|
| 534 |
+
<div class="form-check py-0.5">
|
| 535 |
+
<input class="form-check-input" type="checkbox" id="perm-${p.code}" value="${p.code}" ${isChecked ? 'checked' : ''}>
|
| 536 |
+
<label class="form-check-label" for="perm-${p.code}" style="cursor: pointer;">
|
| 537 |
+
<strong>${escapeHtml(p.code)}</strong> — <span class="small text-muted">${escapeHtml(p.description)}</span>
|
| 538 |
+
</label>
|
| 539 |
+
</div>
|
| 540 |
+
`;
|
| 541 |
+
}).join('');
|
| 542 |
+
if (saveBtn) saveBtn.classList.remove('d-none');
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
// Submit Create User
|
| 548 |
+
if (addForm) {
|
| 549 |
+
addForm.addEventListener('submit', async (e) => {
|
| 550 |
+
e.preventDefault();
|
| 551 |
+
const errAlert = document.getElementById('add-user-error-alert');
|
| 552 |
+
if (errAlert) errAlert.classList.add('d-none');
|
| 553 |
+
|
| 554 |
+
const name = document.getElementById('new-user-name').value;
|
| 555 |
+
const email = document.getElementById('new-user-email').value;
|
| 556 |
+
const password = document.getElementById('new-user-password').value;
|
| 557 |
+
const role_id = parseInt(document.getElementById('new-user-role-select').value, 10);
|
| 558 |
+
|
| 559 |
+
try {
|
| 560 |
+
const res = await fetch('/api/auth/users/create', {
|
| 561 |
+
method: 'POST',
|
| 562 |
+
headers: { 'Content-Type': 'application/json' },
|
| 563 |
+
body: JSON.stringify({ name, email, password, role_id })
|
| 564 |
+
});
|
| 565 |
+
const data = await res.json();
|
| 566 |
+
if (res.ok) {
|
| 567 |
+
addForm.reset();
|
| 568 |
+
addForm.classList.add('d-none');
|
| 569 |
+
btnShowAddForm.classList.remove('d-none');
|
| 570 |
+
await loadRolesAndUsers();
|
| 571 |
+
if (typeof addNotification === 'function') {
|
| 572 |
+
addNotification('User Created', `Created user account for ${name}`);
|
| 573 |
+
}
|
| 574 |
+
} else {
|
| 575 |
+
if (errAlert) {
|
| 576 |
+
errAlert.textContent = data.error || 'Failed to create user';
|
| 577 |
+
errAlert.classList.remove('d-none');
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
} catch (err) {
|
| 581 |
+
if (errAlert) {
|
| 582 |
+
errAlert.textContent = 'Connection error occurred.';
|
| 583 |
+
errAlert.classList.remove('d-none');
|
| 584 |
+
}
|
| 585 |
+
}
|
| 586 |
+
});
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
// Submit Create Custom Role
|
| 590 |
+
if (createRoleForm) {
|
| 591 |
+
createRoleForm.addEventListener('submit', async (e) => {
|
| 592 |
+
e.preventDefault();
|
| 593 |
+
const errAlert = document.getElementById('create-role-error-alert');
|
| 594 |
+
if (errAlert) errAlert.classList.add('d-none');
|
| 595 |
+
|
| 596 |
+
const name = document.getElementById('new-role-name').value;
|
| 597 |
+
const description = document.getElementById('new-role-desc').value;
|
| 598 |
+
|
| 599 |
+
try {
|
| 600 |
+
const res = await fetch('/api/auth/roles/create', {
|
| 601 |
+
method: 'POST',
|
| 602 |
+
headers: { 'Content-Type': 'application/json' },
|
| 603 |
+
body: JSON.stringify({ name, description })
|
| 604 |
+
});
|
| 605 |
+
const data = await res.json();
|
| 606 |
+
if (res.ok) {
|
| 607 |
+
createRoleForm.reset();
|
| 608 |
+
createRoleForm.classList.add('d-none');
|
| 609 |
+
btnShowCreateRole.classList.remove('d-none');
|
| 610 |
+
await loadRolesAndUsers();
|
| 611 |
+
if (typeof addNotification === 'function') {
|
| 612 |
+
addNotification('Role Created', `Custom role '${name}' created successfully`);
|
| 613 |
+
}
|
| 614 |
+
} else {
|
| 615 |
+
if (errAlert) {
|
| 616 |
+
errAlert.textContent = data.error || 'Failed to create role';
|
| 617 |
+
errAlert.classList.remove('d-none');
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
} catch (err) {
|
| 621 |
+
if (errAlert) {
|
| 622 |
+
errAlert.textContent = 'Connection error occurred.';
|
| 623 |
+
errAlert.classList.remove('d-none');
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
});
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
// Submit Update Role Permissions mapping
|
| 630 |
+
const permissionsForm = document.getElementById('update-role-permissions-form');
|
| 631 |
+
if (permissionsForm) {
|
| 632 |
+
permissionsForm.addEventListener('submit', async (e) => {
|
| 633 |
+
e.preventDefault();
|
| 634 |
+
const successAlert = document.getElementById('permissions-update-success-alert');
|
| 635 |
+
const errAlert = document.getElementById('permissions-update-error-alert');
|
| 636 |
+
if (successAlert) successAlert.classList.add('d-none');
|
| 637 |
+
if (errAlert) errAlert.classList.add('d-none');
|
| 638 |
+
|
| 639 |
+
const roleId = parseInt(document.getElementById('selected-role-id-input').value, 10);
|
| 640 |
+
if (!roleId) return;
|
| 641 |
+
|
| 642 |
+
// Gather all checked checkbox values
|
| 643 |
+
const checkedPerms = [];
|
| 644 |
+
const checkboxes = document.getElementById('permissions-checkboxes-container').querySelectorAll('input[type="checkbox"]');
|
| 645 |
+
checkboxes.forEach(cb => {
|
| 646 |
+
if (cb.checked) {
|
| 647 |
+
checkedPerms.push(cb.value);
|
| 648 |
+
}
|
| 649 |
+
});
|
| 650 |
+
|
| 651 |
+
try {
|
| 652 |
+
const res = await fetch(`/api/auth/roles/${roleId}/permissions`, {
|
| 653 |
+
method: 'POST',
|
| 654 |
+
headers: { 'Content-Type': 'application/json' },
|
| 655 |
+
body: JSON.stringify({ permissions: checkedPerms })
|
| 656 |
+
});
|
| 657 |
+
const data = await res.json();
|
| 658 |
+
if (res.ok) {
|
| 659 |
+
if (successAlert) successAlert.classList.remove('d-none');
|
| 660 |
+
await loadRolesAndUsers();
|
| 661 |
+
setTimeout(() => selectRoleForPermissions(roleId), 100);
|
| 662 |
+
} else {
|
| 663 |
+
if (errAlert) {
|
| 664 |
+
errAlert.textContent = data.error || 'Failed to save configuration';
|
| 665 |
+
errAlert.classList.remove('d-none');
|
| 666 |
+
}
|
| 667 |
+
}
|
| 668 |
+
} catch (err) {
|
| 669 |
+
if (errAlert) {
|
| 670 |
+
errAlert.textContent = 'Connection error occurred.';
|
| 671 |
+
errAlert.classList.remove('d-none');
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
});
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
// Run Initial Load
|
| 678 |
+
await loadRolesAndUsers();
|
| 679 |
+
}
|
static/styles.css
CHANGED
|
@@ -5247,3 +5247,70 @@ th {
|
|
| 5247 |
}
|
| 5248 |
}
|
| 5249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5247 |
}
|
| 5248 |
}
|
| 5249 |
|
| 5250 |
+
/* Auth Styles */
|
| 5251 |
+
.auth-overlay {
|
| 5252 |
+
position: fixed;
|
| 5253 |
+
top: 0;
|
| 5254 |
+
left: 0;
|
| 5255 |
+
width: 100vw;
|
| 5256 |
+
height: 100vh;
|
| 5257 |
+
background: radial-gradient(circle at 10% 20%, rgb(240, 243, 248) 0%, rgb(217, 227, 242) 90%);
|
| 5258 |
+
display: flex;
|
| 5259 |
+
align-items: center;
|
| 5260 |
+
justify-content: center;
|
| 5261 |
+
z-index: 9999;
|
| 5262 |
+
}
|
| 5263 |
+
|
| 5264 |
+
.auth-card {
|
| 5265 |
+
background: rgba(255, 255, 255, 0.85);
|
| 5266 |
+
backdrop-filter: blur(20px);
|
| 5267 |
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
| 5268 |
+
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.05);
|
| 5269 |
+
border-radius: 24px;
|
| 5270 |
+
width: 100%;
|
| 5271 |
+
max-width: 440px;
|
| 5272 |
+
padding: 40px;
|
| 5273 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 5274 |
+
}
|
| 5275 |
+
|
| 5276 |
+
.auth-logo {
|
| 5277 |
+
font-family: 'Outfit', 'Inter', sans-serif;
|
| 5278 |
+
font-weight: 800;
|
| 5279 |
+
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
| 5280 |
+
-webkit-background-clip: text;
|
| 5281 |
+
-webkit-text-fill-color: transparent;
|
| 5282 |
+
letter-spacing: -0.5px;
|
| 5283 |
+
margin-bottom: 8px;
|
| 5284 |
+
}
|
| 5285 |
+
|
| 5286 |
+
.auth-tabs {
|
| 5287 |
+
display: flex;
|
| 5288 |
+
background: rgba(0, 0, 0, 0.05);
|
| 5289 |
+
padding: 4px;
|
| 5290 |
+
border-radius: 30px;
|
| 5291 |
+
}
|
| 5292 |
+
|
| 5293 |
+
.auth-tab-btn {
|
| 5294 |
+
flex: 1;
|
| 5295 |
+
border: none;
|
| 5296 |
+
background: transparent;
|
| 5297 |
+
padding: 8px 16px;
|
| 5298 |
+
font-size: 0.88rem;
|
| 5299 |
+
font-weight: 600;
|
| 5300 |
+
border-radius: 20px;
|
| 5301 |
+
color: var(--text-secondary);
|
| 5302 |
+
transition: all 0.2s ease;
|
| 5303 |
+
}
|
| 5304 |
+
|
| 5305 |
+
.auth-tab-btn.active {
|
| 5306 |
+
background: #FFFFFF;
|
| 5307 |
+
color: var(--black);
|
| 5308 |
+
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
|
| 5309 |
+
}
|
| 5310 |
+
|
| 5311 |
+
.app-layout.blur-layout {
|
| 5312 |
+
filter: blur(8px);
|
| 5313 |
+
pointer-events: none;
|
| 5314 |
+
}
|
| 5315 |
+
|
| 5316 |
+
|
templates/index.html
CHANGED
|
@@ -19,13 +19,13 @@
|
|
| 19 |
|
| 20 |
<div class="app-layout">
|
| 21 |
<!-- Sidebar Navigation -->
|
| 22 |
-
<aside class="sidebar">
|
| 23 |
<div class="sidebar-header d-flex justify-content-between align-items-center">
|
| 24 |
<div class="brand-logo">
|
| 25 |
<span id="sidebar-logo-text">ProspectIQ</span>
|
| 26 |
</div>
|
| 27 |
-
<button class="btn p-0 d-none d-lg-inline-flex align-items-center justify-content-center" id="sidebar-toggle-btn" type="button" style="border: none; background: transparent; color: var(--black); font-size: 1.
|
| 28 |
-
|
| 29 |
</button>
|
| 30 |
<!-- Opened and closed with a three horizontal lines icon -->
|
| 31 |
<button class="btn d-lg-none p-0" id="sidebar-mobile-close" type="button" aria-label="Close Sidebar" style="font-size: 1.5rem; color: var(--black); border: none; background: transparent; line-height: 1;">
|
|
@@ -115,42 +115,7 @@
|
|
| 115 |
|
| 116 |
<!-- Custom Dropdown Menu -->
|
| 117 |
<div class="user-profile-dropdown" id="user-profile-dropdown">
|
| 118 |
-
<
|
| 119 |
-
<div class="user-option-item" data-value="1|Admin|admin1">
|
| 120 |
-
<div class="option-avatar admin">A1</div>
|
| 121 |
-
<div>
|
| 122 |
-
<div style="font-weight: 600; color: var(--black); line-height: 1.2;">admin1</div>
|
| 123 |
-
<div style="font-size: 0.68rem; color: var(--text-secondary); line-height: 1.2;">Admin</div>
|
| 124 |
-
</div>
|
| 125 |
-
</div>
|
| 126 |
-
<div class="user-option-item" data-value="2|BD Rep|bd rep1">
|
| 127 |
-
<div class="option-avatar bd-rep">R1</div>
|
| 128 |
-
<div>
|
| 129 |
-
<div style="font-weight: 600; color: var(--black); line-height: 1.2;">bd rep1</div>
|
| 130 |
-
<div style="font-size: 0.68rem; color: var(--text-secondary); line-height: 1.2;">BD Rep</div>
|
| 131 |
-
</div>
|
| 132 |
-
</div>
|
| 133 |
-
<div class="user-option-item" data-value="4|BD Rep|bd rep2">
|
| 134 |
-
<div class="option-avatar bd-rep">R2</div>
|
| 135 |
-
<div>
|
| 136 |
-
<div style="font-weight: 600; color: var(--black); line-height: 1.2;">bd rep2</div>
|
| 137 |
-
<div style="font-size: 0.68rem; color: var(--text-secondary); line-height: 1.2;">BD Rep</div>
|
| 138 |
-
</div>
|
| 139 |
-
</div>
|
| 140 |
-
<div class="user-option-item" data-value="5|BD Rep|bd rep3">
|
| 141 |
-
<div class="option-avatar bd-rep">R3</div>
|
| 142 |
-
<div>
|
| 143 |
-
<div style="font-weight: 600; color: var(--black); line-height: 1.2;">bd rep3</div>
|
| 144 |
-
<div style="font-size: 0.68rem; color: var(--text-secondary); line-height: 1.2;">BD Rep</div>
|
| 145 |
-
</div>
|
| 146 |
-
</div>
|
| 147 |
-
<div class="user-option-item" data-value="3|Viewer|viewer1">
|
| 148 |
-
<div class="option-avatar viewer">V1</div>
|
| 149 |
-
<div>
|
| 150 |
-
<div style="font-weight: 600; color: var(--black); line-height: 1.2;">viewer1</div>
|
| 151 |
-
<div style="font-size: 0.68rem; color: var(--text-secondary); line-height: 1.2;">Viewer</div>
|
| 152 |
-
</div>
|
| 153 |
-
</div>
|
| 154 |
</div>
|
| 155 |
</div>
|
| 156 |
</aside>
|
|
@@ -161,7 +126,7 @@
|
|
| 161 |
<!-- Main Content Area -->
|
| 162 |
<main class="main-content">
|
| 163 |
<!-- Top Command Bar -->
|
| 164 |
-
<header class="top-command-header">
|
| 165 |
<!-- Left Section (for mobile hamburger toggle) -->
|
| 166 |
<div class="header-left">
|
| 167 |
<button class="btn p-0" id="sidebar-mobile-toggle" type="button" aria-label="Toggle Sidebar">
|
|
@@ -316,6 +281,7 @@
|
|
| 316 |
<script src="/static/js/components/inline_assistant.js"></script>
|
| 317 |
|
| 318 |
<!-- Page Views -->
|
|
|
|
| 319 |
<script src="/static/js/views/mockups.js"></script>
|
| 320 |
<script src="/static/js/views/home.js"></script>
|
| 321 |
<script src="/static/js/views/companies.js"></script>
|
|
@@ -331,5 +297,54 @@
|
|
| 331 |
<button id="floating-ai-assistant-btn" class="floating-ai-assistant-btn" type="button" title="Open AI Assistant">
|
| 332 |
AI
|
| 333 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
</body>
|
| 335 |
</html>
|
|
|
|
| 19 |
|
| 20 |
<div class="app-layout">
|
| 21 |
<!-- Sidebar Navigation -->
|
| 22 |
+
<aside class="sidebar" style="display: none;">
|
| 23 |
<div class="sidebar-header d-flex justify-content-between align-items-center">
|
| 24 |
<div class="brand-logo">
|
| 25 |
<span id="sidebar-logo-text">ProspectIQ</span>
|
| 26 |
</div>
|
| 27 |
+
<button class="btn p-0 d-none d-lg-inline-flex align-items-center justify-content-center" id="sidebar-toggle-btn" type="button" style="border: none; background: transparent; color: var(--black); font-size: 1.1rem; cursor: pointer; padding: 6px; transition: transform 0.2s;" title="Toggle Sidebar">
|
| 28 |
+
<i class="bi bi-chevron-left" id="sidebar-toggle-icon"></i>
|
| 29 |
</button>
|
| 30 |
<!-- Opened and closed with a three horizontal lines icon -->
|
| 31 |
<button class="btn d-lg-none p-0" id="sidebar-mobile-close" type="button" aria-label="Close Sidebar" style="font-size: 1.5rem; color: var(--black); border: none; background: transparent; line-height: 1;">
|
|
|
|
| 115 |
|
| 116 |
<!-- Custom Dropdown Menu -->
|
| 117 |
<div class="user-profile-dropdown" id="user-profile-dropdown">
|
| 118 |
+
<!-- Dynamic actions and Sign Out added via app.js -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
</aside>
|
|
|
|
| 126 |
<!-- Main Content Area -->
|
| 127 |
<main class="main-content">
|
| 128 |
<!-- Top Command Bar -->
|
| 129 |
+
<header class="top-command-header" style="display: none;">
|
| 130 |
<!-- Left Section (for mobile hamburger toggle) -->
|
| 131 |
<div class="header-left">
|
| 132 |
<button class="btn p-0" id="sidebar-mobile-toggle" type="button" aria-label="Toggle Sidebar">
|
|
|
|
| 281 |
<script src="/static/js/components/inline_assistant.js"></script>
|
| 282 |
|
| 283 |
<!-- Page Views -->
|
| 284 |
+
<script src="/static/js/views/admin_portal.js"></script>
|
| 285 |
<script src="/static/js/views/mockups.js"></script>
|
| 286 |
<script src="/static/js/views/home.js"></script>
|
| 287 |
<script src="/static/js/views/companies.js"></script>
|
|
|
|
| 297 |
<button id="floating-ai-assistant-btn" class="floating-ai-assistant-btn" type="button" title="Open AI Assistant">
|
| 298 |
AI
|
| 299 |
</button>
|
| 300 |
+
|
| 301 |
+
<!-- Dynamic Auth Overlay -->
|
| 302 |
+
<div id="auth-container" class="auth-overlay animate-fade-in d-none">
|
| 303 |
+
<div class="auth-card">
|
| 304 |
+
<div class="auth-card-header text-center">
|
| 305 |
+
<h2 class="auth-logo">ProspectIQ</h2>
|
| 306 |
+
<p class="auth-subtitle" id="auth-subtitle">Sign in to manage your pipeline</p>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<!-- Toggle tabs for Sign In / Register -->
|
| 310 |
+
<div class="auth-tabs mb-4">
|
| 311 |
+
<button class="auth-tab-btn active" id="auth-tab-signin" type="button">Sign In</button>
|
| 312 |
+
<button class="auth-tab-btn" id="auth-tab-signup" type="button">Register Org</button>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<form id="auth-form" class="auth-form">
|
| 316 |
+
<!-- Organization Name (Visible only on Signup) -->
|
| 317 |
+
<div class="form-group mb-3 d-none" id="auth-group-org">
|
| 318 |
+
<label for="auth-org-name" class="form-label">Organization Name</label>
|
| 319 |
+
<input type="text" id="auth-org-name" class="form-control" placeholder="e.g. MapleMPSS Sourcing">
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<!-- Name (Visible only on Signup) -->
|
| 323 |
+
<div class="form-group mb-3 d-none" id="auth-group-name">
|
| 324 |
+
<label for="auth-user-name" class="form-label">Full Name</label>
|
| 325 |
+
<input type="text" id="auth-user-name" class="form-control" placeholder="John Doe">
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
<!-- Email -->
|
| 329 |
+
<div class="form-group mb-3">
|
| 330 |
+
<label for="auth-email" class="form-label">Email Address</label>
|
| 331 |
+
<input type="email" id="auth-email" class="form-control" placeholder="name@company.com" required>
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
<!-- Password -->
|
| 335 |
+
<div class="form-group mb-4">
|
| 336 |
+
<label for="auth-password" class="form-label">Password</label>
|
| 337 |
+
<input type="password" id="auth-password" class="form-control" placeholder="••••••••" required>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
<!-- Error message container -->
|
| 341 |
+
<div id="auth-error-alert" class="alert alert-danger d-none" role="alert"></div>
|
| 342 |
+
|
| 343 |
+
<button type="submit" class="btn btn-dark-custom w-100 py-2.5 rounded-pill btn-auth-submit" id="auth-submit-btn">
|
| 344 |
+
Sign In
|
| 345 |
+
</button>
|
| 346 |
+
</form>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
</body>
|
| 350 |
</html>
|