From 0190da30d475c90b82a78cb4f446f0ea1a2b10d0 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 4 Jun 2026 13:25:57 -0400 Subject: [PATCH] 06/04 Optimize app --- app.py | 12 ++++ models.py | 4 +- routes/auth.py | 2 +- routes/user_dashboard.py | 5 +- static/js/app.js | 66 ++++++++++++++------ templates/base.html | 16 +++++ templates/bid_tracker.html | 7 ++- templates/user/dashboard.html | 111 +++++++++++++++++++++------------- 8 files changed, 158 insertions(+), 65 deletions(-) diff --git a/app.py b/app.py index 01778a8..cc96e49 100644 --- a/app.py +++ b/app.py @@ -82,6 +82,18 @@ def create_app(): response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data:; " + "connect-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'self'" + ) return response # ── 413 handler: file too large ──────────────────────────────────────────── diff --git a/models.py b/models.py index dcc67e5..413b052 100644 --- a/models.py +++ b/models.py @@ -56,7 +56,7 @@ def log_action(user_id, action, entity=None, entity_id=None, detail=None): # ─── Authentication ──────────────────────────────────────────────────────────── -def authenticate(username: str, password: str): +def authenticate(username: str, password: str, ip_address: str = None): conn = None try: conn = get_connection() @@ -66,7 +66,7 @@ def authenticate(username: str, password: str): if not user or not _verify_password(password, user["password"]): logger.warning(f"Failed login attempt for username='{username}'.") cur.close() - record_failed_attempt(username) + record_failed_attempt(username, ip_address) return None if _needs_rehash(user["password"]): new_hash = _hash_password(password) diff --git a/routes/auth.py b/routes/auth.py index fb23990..3230557 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -35,7 +35,7 @@ def login(): error = f"Account locked. Try again in {mins}m {secs}s." logger.warning(f"Login blocked for '{username}' — still locked ({seconds_remaining}s remaining).") else: - user = authenticate(username, password) + user = authenticate(username, password, ip_address=ip) if user: session.clear() # prevent session fixation session.permanent = True diff --git a/routes/user_dashboard.py b/routes/user_dashboard.py index f806d7b..74361e8 100644 --- a/routes/user_dashboard.py +++ b/routes/user_dashboard.py @@ -9,7 +9,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from models import ( get_today_checks, mark_website_checked, unmark_website_checked, update_check_note, get_user_active_shifts, get_website_credentials, - get_website_url, + get_website_url, log_action, ) from utils.decorators import login_required @@ -86,6 +86,9 @@ def view_credentials(website_id): """JSON endpoint: return decrypted credentials for a site.""" try: creds = get_website_credentials(website_id) + user_id = session["user"]["id"] + log_action(user_id, "VIEW_CREDENTIALS", "websites", website_id, + f"User viewed credentials for website {website_id}.") return jsonify([{ "label": c.get("label", ""), "username": c.get("username", ""), diff --git a/static/js/app.js b/static/js/app.js index 7d623c0..7ec0aa4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -24,9 +24,9 @@ function closeModal(id) { } } -// Close modal when clicking the backdrop +// Close modal when clicking the backdrop (skips modals with data-no-backdrop-close) document.addEventListener('click', (e) => { - if (e.target.classList.contains('modal-overlay')) { + if (e.target.classList.contains('modal-overlay') && !e.target.hasAttribute('data-no-backdrop-close')) { e.target.classList.remove('open'); document.body.style.overflow = ''; } @@ -162,30 +162,60 @@ document.addEventListener('DOMContentLoaded', () => { /* ── Session timeout warning ───────────────────────────────── */ (function sessionWarning() { - const WARN_BEFORE_MS = 5 * 60 * 1000; // warn 5 min before expiry - const SESSION_MS = 30 * 60 * 1000; // match Flask SESSION_LIFETIME + const WARN_BEFORE_MS = 5 * 60 * 1000; + const SESSION_MS = 30 * 60 * 1000; - let warningTimer = null; - let expireTimer = null; + var warningTimer = null, expireTimer = null, countdownInterval = null; + + function isWarningShowing() { + var m = document.getElementById('modal-session-warning'); + return m && m.classList.contains('open'); + } + + function clearCountdown() { + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } + } + + function startCountdown() { + var remaining = Math.round(WARN_BEFORE_MS / 1000); + var el = document.getElementById('session-countdown'); + clearCountdown(); + countdownInterval = setInterval(function() { + remaining--; + if (el) { + var m = Math.floor(remaining / 60), s = remaining % 60; + el.textContent = m + ':' + (s < 10 ? '0' : '') + s; + } + if (remaining <= 0) { clearCountdown(); window.location.href = '/login'; } + }, 1000); + } function resetTimers() { - clearTimeout(warningTimer); - clearTimeout(expireTimer); - warningTimer = setTimeout(() => { - if (confirm('Your session will expire in 5 minutes. Click OK to stay logged in.')) { - fetch('/ping', { credentials: 'same-origin' }).catch(() => {}); - resetTimers(); - } + clearTimeout(warningTimer); clearTimeout(expireTimer); clearCountdown(); + if (isWarningShowing()) closeModal('modal-session-warning'); + + warningTimer = setTimeout(function() { + openModal('modal-session-warning'); + startCountdown(); }, SESSION_MS - WARN_BEFORE_MS); - expireTimer = setTimeout(() => { - alert('Your session has expired. You will be redirected to login.'); - window.location.href = '/auth/login'; + expireTimer = setTimeout(function() { + window.location.href = '/login'; }, SESSION_MS); } - ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(evt => { - document.addEventListener(evt, () => resetTimers(), { passive: true }); + document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('btn-stay-logged-in'); + if (btn) btn.addEventListener('click', function() { + fetch('/ping', { credentials: 'same-origin' }).catch(function(){}); + resetTimers(); + }); + }); + + ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(function(evt) { + document.addEventListener(evt, function() { + if (!isWarningShowing()) resetTimers(); + }, { passive: true }); }); resetTimers(); diff --git a/templates/base.html b/templates/base.html index 3fb781f..faaf48d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -69,6 +69,22 @@ {% block content %}{% endblock %} + + + diff --git a/templates/bid_tracker.html b/templates/bid_tracker.html index cddbe35..f2b4093 100644 --- a/templates/bid_tracker.html +++ b/templates/bid_tracker.html @@ -258,9 +258,12 @@ function renderBidList(bids) { var dueD = new Date(b.due_date.slice(0, 10)); dueD.setHours(0, 0, 0, 0); var diff = Math.round((dueD - today) / 86400000); if (diff < 0) { - urgencyBadge = 'Overdue'; + var od = Math.abs(diff); + urgencyBadge = 'Overdue by ' + od + (od === 1 ? ' day' : ' days') + ''; + } else if (diff === 0) { + urgencyBadge = 'Due today'; } else if (diff <= 7) { - urgencyBadge = 'Due Soon'; + urgencyBadge = 'Due in ' + diff + (diff === 1 ? ' day' : ' days') + ''; } } diff --git a/templates/user/dashboard.html b/templates/user/dashboard.html index eb147b0..419cd7b 100644 --- a/templates/user/dashboard.html +++ b/templates/user/dashboard.html @@ -87,6 +87,7 @@
@@ -209,7 +210,7 @@
Loading…
@@ -232,24 +233,28 @@ function toggleGroup(name) { } /* ── Search — shows matching cards, auto-expands collapsed groups ── */ +var _searchTimer = null; document.getElementById('site-search').addEventListener('input', function() { - var q = this.value.trim().toLowerCase(); - document.querySelectorAll('.site-card').forEach(function(card) { - var match = !q || card.dataset.name.includes(q) || card.dataset.url.includes(q); - card.style.display = match ? '' : 'none'; + var val = this.value; + clearTimeout(_searchTimer); + _searchTimer = setTimeout(function() { + var q = val.trim().toLowerCase(); + document.querySelectorAll('.site-card').forEach(function(card) { + var match = !q || card.dataset.name.includes(q) || card.dataset.url.includes(q); + card.style.display = match ? '' : 'none'; - // Auto-expand the group containing a matched card - if (match && q) { - var group = card.closest('.site-group'); - if (group) { - group.style.display = ''; - var name = group.id.replace('group-', ''); - var toggle = document.getElementById('toggle-' + name); - if (toggle) toggle.textContent = '▾'; - _collapsed[name] = false; + if (match && q) { + var group = card.closest('.site-group'); + if (group) { + group.style.display = ''; + var name = group.id.replace('group-', ''); + var toggle = document.getElementById('toggle-' + name); + if (toggle) toggle.textContent = '▾'; + _collapsed[name] = false; + } } - } - }); + }); + }, 250); }); /* ── Bulk select ───────────────────────────────────────────── */ @@ -282,11 +287,20 @@ document.getElementById('site-list').addEventListener('click', function(e) { /* Mark Checked */ if (btn.classList.contains('js-check')) { + var siteName = btn.dataset.name; + btn.disabled = true; fetch('/dashboard/check/' + btn.dataset.id, { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrfToken()}, body: 'user_note=' - }).then(function() { location.reload(); }); + }).then(function() { + var toast = document.createElement('div'); + toast.className = 'alert alert-success'; + toast.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;min-width:220px;box-shadow:var(--shadow)'; + toast.textContent = '✔ ' + siteName + ' marked checked'; + document.body.appendChild(toast); + setTimeout(function() { location.reload(); }, 700); + }); return; } @@ -340,6 +354,11 @@ document.getElementById('site-list').addEventListener('click', function(e) { /* ── Credential modal actions ──────────────────────────────── */ document.getElementById('modal-creds').addEventListener('click', function(e) { + /* Clear body when clicking the backdrop */ + if (e.target === this) { + document.getElementById('creds-body').innerHTML = ''; + return; + } var copyBtn = e.target.closest('[data-copy]'); if (copyBtn) { copyToClipboard(copyBtn.dataset.copy, copyBtn); return; } var toggleBtn = e.target.closest('[data-toggle-pw]'); @@ -359,30 +378,40 @@ function esc(str) { }); } -/* ── Health dots (server-side probe) ───────────────────────── */ -document.querySelectorAll('.site-card').forEach(function(card) { - var id = card.dataset.id; - var dot = document.getElementById('health-' + id); - if (!dot) return; - fetch('/dashboard/health/' + id, {credentials: 'same-origin'}) - .then(function(r) { return r.json(); }) - .then(function(d) { - if (d.status === 'ok') { - dot.style.color = d.ms > 3000 ? '#d97706' : '#16a34a'; - dot.title = 'Reachable (' + d.ms + 'ms)'; - } else if (d.status === 'timeout') { - dot.style.color = '#d97706'; - dot.title = 'Timeout (>6s)'; - } else { - dot.style.color = '#dc2626'; - dot.title = 'Unreachable'; - } - }) - .catch(function() { - dot.style.color = '#9ca3af'; - dot.title = 'Health check failed'; - }); -}); +/* ── Health dots — batched with max 4 concurrent requests ─── */ +(function() { + var cards = Array.from(document.querySelectorAll('.site-card')); + var CONCURRENCY = 4, idx = 0; + + function probe() { + if (idx >= cards.length) return; + var card = cards[idx++]; + var id = card.dataset.id; + var dot = document.getElementById('health-' + id); + if (!dot) { probe(); return; } + fetch('/dashboard/health/' + id, {credentials: 'same-origin'}) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.status === 'ok') { + dot.style.color = d.ms > 3000 ? '#d97706' : '#16a34a'; + dot.title = 'Reachable (' + d.ms + 'ms)'; + } else if (d.status === 'timeout') { + dot.style.color = '#d97706'; + dot.title = 'Timeout (>6s)'; + } else { + dot.style.color = '#dc2626'; + dot.title = 'Unreachable'; + } + }) + .catch(function() { + dot.style.color = '#9ca3af'; + dot.title = 'Health check failed'; + }) + .finally(probe); + } + + for (var i = 0; i < Math.min(CONCURRENCY, cards.length); i++) probe(); +})();