Ashwin commited on
Commit
8dbbe64
·
1 Parent(s): 0400ba7

Implement role-based access control, admin portal, and global loader UI

Browse files
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://localhost: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
 
 
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
- user_id = request.headers.get("X-User-Id")
465
- role = request.headers.get("X-User-Role")
466
- name = request.headers.get("X-User-Name")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  try:
468
- user_id = int(user_id) if user_id else 2
469
- except (ValueError, TypeError):
470
- user_id = 2
471
- role = role if role else "BD Rep"
472
- name = name if name else "bd rep1"
473
- return user_id, role, name
 
 
 
 
 
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://localhost:6379/0"
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
- """, (user_id,))
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
- const userId = sessionStorage.getItem('prospectiq_user_id') || '2';
7
- const userRole = sessionStorage.getItem('prospectiq_user_role') || 'BD Rep';
8
- const userName = sessionStorage.getItem('prospectiq_user_name') || 'bd rep1';
 
 
 
 
 
 
 
 
 
 
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 (sidebarToggleBtn) sidebarToggleBtn.textContent = '>>';
 
 
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
- sidebarToggleBtn.textContent = willCollapse ? '>>' : '<<';
343
- if (sidebarLogoText) {
344
- sidebarLogoText.textContent = willCollapse ? 'PIQ' : 'ProspectIQ';
345
- }
346
- const profileDropdown = document.getElementById('user-profile-dropdown');
347
- const profileTrigger = document.getElementById('user-profile-trigger');
348
- if (profileDropdown) {
349
- profileDropdown.style.display = 'none';
350
- profileDropdown.classList.remove('is-floating');
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
- // Wire User Simulator Switcher (Custom Profile Widget in Sidebar Footer)
 
 
 
392
  const profileTrigger = document.getElementById('user-profile-trigger');
393
  const profileDropdown = document.getElementById('user-profile-dropdown');
394
 
395
  if (profileTrigger && profileDropdown) {
396
- const savedUserId = sessionStorage.getItem('prospectiq_user_id') || '2';
397
- const savedRole = sessionStorage.getItem('prospectiq_user_role') || 'BD Rep';
398
- const savedName = sessionStorage.getItem('prospectiq_user_name') || 'bd rep1';
399
-
400
- // Function to update UI display of current user
401
- const updateActiveUserUI = (userId, role, name) => {
402
- const avatarEl = document.getElementById('user-profile-avatar');
403
- const nameEl = document.getElementById('user-profile-name');
404
- const roleEl = document.getElementById('user-profile-role');
405
-
406
- if (nameEl) nameEl.textContent = name;
407
- if (roleEl) roleEl.textContent = role;
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
- // Mark active item in dropdown list
430
- const optionItems = profileDropdown.querySelectorAll('.user-option-item');
431
- optionItems.forEach(item => {
432
- const val = item.getAttribute('data-value');
433
- if (val.startsWith(`${userId}|`)) {
434
- item.classList.add('active');
435
- } else {
436
- item.classList.remove('active');
437
- }
 
 
 
 
438
  });
439
- };
440
 
441
- // Initial setup on page load
442
- updateActiveUserUI(savedUserId, savedRole, savedName);
443
-
444
- const resetProfileDropdownPosition = () => {
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
- sessionStorage.setItem('prospectiq_user_id', userId);
528
- sessionStorage.setItem('prospectiq_user_role', role);
529
- sessionStorage.setItem('prospectiq_user_name', name);
530
-
531
- updateActiveUserUI(userId, role, name);
532
- closeProfileDropdown();
533
 
534
- if (typeof addNotification === 'function') {
535
- addNotification('User Switched', `Simulating as ${name} (${role})`);
536
- }
537
- if (typeof loadDataFromDb === 'function') {
538
- await loadDataFromDb();
539
- }
540
- if (typeof handleNavigation === 'function') {
541
- handleNavigation();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  }
543
  });
544
- });
545
  }
546
 
547
  // Listen for hash change for routing
548
  window.addEventListener('hashchange', handleNavigation);
549
 
550
- // Load all initial data in one shot via bootstrap endpoint
551
- await loadDataFromDb();
552
-
553
- // Initial load check
554
- handleNavigation();
 
 
 
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
- // Populate initial tasks if none exist
15
  let tasks = JSON.parse(localStorage.getItem('prospectiq-tasks') || '[]');
16
- if (tasks.length === 0) {
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
- renderHome(pageContent);
 
 
 
 
 
 
 
 
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.25rem; font-weight: bold; cursor: pointer; padding: 4px;" title="Toggle Sidebar">
28
- &lt;&lt;
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
- <div class="dropdown-header-title">Switch User Account</div>
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>