05/24 Enhance functionalities

This commit is contained in:
2026-05-24 18:10:11 -04:00
parent 6605484927
commit fbf271ed71
15 changed files with 593 additions and 30 deletions
+11 -8
View File
@@ -99,12 +99,15 @@ Without this, sessions and CSRF tokens are broken across the 4 Gunicorn workers.
| `app.py` | Flask factory | Registers all 11 blueprints; `CSRFProtect(app)`; `hhmm` template filter; logs WARNING if `SECRET_KEY` not set | | `app.py` | Flask factory | Registers all 11 blueprints; `CSRFProtect(app)`; `hhmm` template filter; logs WARNING if `SECRET_KEY` not set |
| `wsgi.py` | Gunicorn entry point | Only place that calls `create_app()` — do not add a second call elsewhere | | `wsgi.py` | Gunicorn entry point | Only place that calls `create_app()` — do not add a second call elsewhere |
| `config.py` | DB config, DDL, settings | Calls `load_dotenv()` at top — **must be before `DB_CONFIG` dict**; contains safe re-runnable migrations | | `config.py` | DB config, DDL, settings | Calls `load_dotenv()` at top — **must be before `DB_CONFIG` dict**; contains safe re-runnable migrations |
| `models.py` | All DB queries | ~1,430 lines; no ORM; every function opens/closes its own connection; `import datetime` at top | | `models.py` | All DB queries | ~1,650 lines; no ORM; every function opens/closes its own connection; `import datetime` at top |
| `utils/crypto.py` | Fernet encryption | Must match desktop exactly | | `utils/crypto.py` | Fernet encryption | Must match desktop exactly |
| `utils/decorators.py` | `@login_required`, `@admin_required` | Simple session checks | | `utils/decorators.py` | `@login_required`, `@admin_required` | Simple session checks |
| `utils/email.py` | SMTP email helper | `send_email(to, subject, body_text)` reads smtp settings from `app_settings`; used by bid reminders and password reset |
| `static/css/style.css` | Full design system | Light theme, DM Sans + DM Mono, CSS variables in `:root` | | `static/css/style.css` | Full design system | Light theme, DM Sans + DM Mono, CSS variables in `:root` |
| `static/js/app.js` | Global JS utilities | `openModal()`, `closeModal()`, `copyToClipboard()`, session timeout warning, `getCsrfToken()`, CSRF auto-inject IIFE | | `static/js/app.js` | Global JS utilities | `openModal()`, `closeModal()`, `copyToClipboard()`, `timeAgo()`, mobile sidebar toggle, session timeout warning, `getCsrfToken()`, CSRF auto-inject IIFE |
| `templates/login.html` | Standalone login page | Does NOT extend `base.html`; has its own `<head>` and no `app.js`; CSRF token must be a direct hidden input | | `templates/login.html` | Standalone login page | Does NOT extend `base.html`; has its own `<head>` and no `app.js`; CSRF token must be a direct hidden input |
| `templates/forgot_password.html` | Standalone forgot-password page | Same constraints as `login.html` — standalone, direct CSRF hidden input, no `app.js` |
| `templates/reset_password.html` | Standalone reset-password page | Same constraints as `login.html` — standalone, direct CSRF hidden input, no `app.js` |
--- ---
@@ -333,9 +336,9 @@ mysql -u webchecker_user -p webchecker -e "SELECT key_name, value FROM app_setti
- [ ] **Paginate bid list**`get_all_bids()` fetches all rows with no limit; add `LIMIT`/`OFFSET` to the model query and a "load more" button in the split-pane list - [ ] **Paginate bid list**`get_all_bids()` fetches all rows with no limit; add `LIMIT`/`OFFSET` to the model query and a "load more" button in the split-pane list
### Functionality ### Functionality
- [ ] **Missed-shift alerting**Query or report that flags shifts where zero `shift_checks` records exist for a given date, surfaced on the admin dashboard or via email - [x] **Missed-shift alerting**`get_missed_shifts_today()` in `models.py` queries shift+user pairs scheduled today with 0 checks; surfaced as a warning card on the admin dashboard
- [ ] **Bid deadline email reminders**Use the existing `email.smtp_*` settings to send a daily digest of bids with `due_date` within the next 7 days - [x] **Bid deadline email reminders**`get_bids_due_soon()` + `get_admin_emails()` in `models.py`; `send_reminders` route in `bid_tracker.py`; "📧 Remind" button in bid tracker toolbar (admin only); uses `utils/email.py`
- [ ] **Server-side health checks**Replace the Google favicon proxy in the user dashboard with a `/dashboard/health/<id>` route that makes a server-side `HEAD` request (with short timeout) for a real reachability signal - [x] **Server-side health checks**`/dashboard/health/<id>` in `user_dashboard.py` does a server-side HEAD request; user dashboard JS updated to call this instead of the Google favicon proxy
- [ ] **Password reset via email**Time-limited token flow so users can self-service instead of requiring an admin edit; needs a `password_reset_tokens` table and SMTP integration - [x] **Password reset via email**`password_reset_tokens` table (migration-safe DDL in `config.py`); model functions in `models.py`; `/forgot-password` and `/reset-password/<token>` routes in `auth.py`; standalone templates `forgot_password.html` and `reset_password.html`; "Forgot password?" link on login page; uses `utils/email.py`
- [ ] **"Copy password" button in Credentials modal** — Wire `copyToClipboard()` (already in `app.js`) to the password field in the user dashboard credentials modal - [x] **"Copy password" button in Credentials modal** — Already implemented in the original code via `data-copy` attribute and delegated click handler in `user/dashboard.html`
- [ ] **Shift calendar view** — Weekly grid (MonSun columns, shifts as rows) on the admin shifts page to make schedule gaps and overlaps visible at a glance - [x] **Shift calendar view** — Weekly grid tab added to admin shifts page; MonSun columns, active shifts as rows; server-side rendered with Jinja2 using existing `day_map` data
+12
View File
@@ -311,6 +311,18 @@ def initialize_database():
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""", """,
""" """
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_prt_token (token),
INDEX idx_prt_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key_name VARCHAR(100) NOT NULL PRIMARY KEY, key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NULL, value TEXT NULL,
+188
View File
@@ -1480,3 +1480,191 @@ def purge_app_log(older_than_days: int = 30):
finally: finally:
if conn: if conn:
conn.close() conn.close()
# ─── Missed Shifts ────────────────────────────────────────────────────────────
def get_missed_shifts_today() -> list:
"""Return shift+user pairs scheduled today where the user has checked 0 sites."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT s.name AS shift_name,
u.id AS user_id, u.username,
COALESCE(u.full_name, u.username) AS full_name,
COUNT(DISTINCT sw.website_id) AS total_sites,
COUNT(DISTINCT sc.website_id) AS checked_sites
FROM shifts s
JOIN shift_users su ON su.shift_id = s.id
JOIN users u ON u.id = su.user_id AND u.is_active = 1
JOIN shift_websites sw ON sw.shift_id = s.id
LEFT JOIN shift_checks sc
ON sc.user_id = su.user_id
AND sc.website_id = sw.website_id
AND DATE(sc.checked_at) = CURDATE()
WHERE s.is_active = 1
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0
GROUP BY s.id, u.id
HAVING total_sites > 0 AND checked_sites = 0
ORDER BY s.name, u.username
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
# ─── Bid Reminders ────────────────────────────────────────────────────────────
def get_bids_due_soon(days: int = 7) -> list:
"""Return open/monitoring bids with due_date within the next `days` days."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT b.*, u.username AS added_by_username, u.full_name AS added_by_full_name"
" FROM bid_tracker b"
" LEFT JOIN users u ON u.id = b.added_by"
" WHERE b.status IN ('open','monitoring')"
" AND b.due_date IS NOT NULL"
" AND b.due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL %s DAY)"
" ORDER BY b.due_date ASC",
(days,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_admin_emails() -> list:
"""Return email addresses of all active admin users that have one set."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT email FROM users"
" WHERE role='admin' AND is_active=1 AND email IS NOT NULL AND email != ''"
)
rows = cur.fetchall()
cur.close()
return [r["email"] for r in rows]
finally:
if conn:
conn.close()
# ─── Server-side Health Check ─────────────────────────────────────────────────
def get_website_url(website_id: int):
"""Return the URL of an active website by ID, or None if not found."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT url FROM websites WHERE id=%s AND is_active=1", (website_id,))
row = cur.fetchone()
cur.close()
return row[0] if row else None
finally:
if conn:
conn.close()
# ─── Password Reset ───────────────────────────────────────────────────────────
def get_user_by_email(email: str):
"""Return the active user matching the given email (case-insensitive), or None."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT * FROM users WHERE LOWER(email)=%s AND is_active=1 LIMIT 1",
(email.lower(),),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def create_password_reset_token(user_id: int) -> str:
"""Generate a secure 1-hour reset token; invalidates any prior token for the user."""
import secrets
token = secrets.token_urlsafe(48)
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM password_reset_tokens WHERE user_id=%s", (user_id,))
cur.execute(
"INSERT INTO password_reset_tokens (user_id, token, expires_at)"
" VALUES (%s, %s, DATE_ADD(NOW(), INTERVAL 1 HOUR))",
(user_id, token),
)
conn.commit()
cur.close()
return token
finally:
if conn:
conn.close()
def get_password_reset_user(token: str):
"""Return the user row for a valid, unexpired reset token, or None."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT u.* FROM password_reset_tokens t"
" JOIN users u ON u.id = t.user_id"
" WHERE t.token=%s AND t.expires_at > NOW()",
(token,),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def consume_password_reset_token(token: str, new_password: str) -> bool:
"""Validate token, update the user's password, and delete the token. Returns True on success."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT user_id FROM password_reset_tokens WHERE token=%s AND expires_at > NOW()",
(token,),
)
row = cur.fetchone()
if not row:
cur.close()
return False
user_id = row["user_id"]
new_hash = _hash_password(new_password)
cur = conn.cursor()
cur.execute("UPDATE users SET password=%s WHERE id=%s", (new_hash, user_id))
cur.execute("DELETE FROM password_reset_tokens WHERE token=%s", (token,))
conn.commit()
cur.close()
return True
finally:
if conn:
conn.close()
+7 -2
View File
@@ -4,7 +4,7 @@ routes/admin_dashboard.py — Admin dashboard: today's completion stats.
import logging import logging
from flask import Blueprint, render_template from flask import Blueprint, render_template
from models import get_admin_dashboard_stats from models import get_admin_dashboard_stats, get_missed_shifts_today
from utils.decorators import admin_required from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_dashboard") logger = logging.getLogger("routes.admin_dashboard")
@@ -19,4 +19,9 @@ def dashboard():
except Exception as e: except Exception as e:
logger.error(f"Dashboard stats error: {e}") logger.error(f"Dashboard stats error: {e}")
stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0} stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0}
return render_template("admin/dashboard.html", stats=stats) try:
missed = get_missed_shifts_today()
except Exception as e:
logger.error(f"Missed shifts error: {e}")
missed = []
return render_template("admin/dashboard.html", stats=stats, missed=missed)
+64 -1
View File
@@ -4,8 +4,13 @@ routes/auth.py — Authentication routes: login, logout, change-password.
import logging import logging
from flask import Blueprint, render_template, request, session, redirect, url_for, flash from flask import Blueprint, render_template, request, session, redirect, url_for, flash
from models import authenticate, check_login_allowed, change_password, log_action from models import (
authenticate, check_login_allowed, change_password, log_action,
get_user_by_email, create_password_reset_token,
get_password_reset_user, consume_password_reset_token,
)
from utils.decorators import login_required from utils.decorators import login_required
from utils.email import send_email
logger = logging.getLogger("routes.auth") logger = logging.getLogger("routes.auth")
auth_bp = Blueprint("auth", __name__) auth_bp = Blueprint("auth", __name__)
@@ -95,3 +100,61 @@ def ping():
"""Keep-alive endpoint for the session timeout warning in app.js.""" """Keep-alive endpoint for the session timeout warning in app.js."""
session.modified = True session.modified = True
return "", 204 return "", 204
@auth_bp.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if "user" in session:
return redirect(url_for("index"))
if request.method == "GET":
return render_template("forgot_password.html")
email = request.form.get("email", "").strip().lower()
if email:
user = get_user_by_email(email)
if user and user.get("email"):
try:
token = create_password_reset_token(user["id"])
reset_url = url_for("auth.reset_password", token=token, _external=True)
body = (
f"Hi {user.get('full_name') or user['username']},\n\n"
f"A password reset was requested for your Website Checker account.\n"
f"Click the link below to set a new password (valid for 1 hour):\n\n"
f"{reset_url}\n\n"
f"If you did not request this, you can safely ignore this email.\n"
)
send_email(user["email"], "Website Checker — Password Reset", body)
except Exception as e:
logger.error(f"Password reset email error for '{email}': {e}")
# Always show the same message to prevent user enumeration
flash("If that email is registered, a reset link has been sent.", "info")
return render_template("forgot_password.html")
@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
user = get_password_reset_user(token)
if not user:
flash("This reset link is invalid or has expired.", "danger")
return redirect(url_for("auth.forgot_password"))
if request.method == "GET":
return render_template("reset_password.html", token=token)
password = request.form.get("password", "")
confirm = request.form.get("confirm_password", "")
if len(password) < 8:
flash("Password must be at least 8 characters.", "danger")
return render_template("reset_password.html", token=token)
if password != confirm:
flash("Passwords do not match.", "danger")
return render_template("reset_password.html", token=token)
if consume_password_reset_token(token, password):
logger.info(f"Password reset successfully for user id={user['id']}.")
flash("Password reset successfully. Please log in.", "success")
return redirect(url_for("auth.login"))
flash("Reset link expired or already used. Please request a new one.", "danger")
return redirect(url_for("auth.forgot_password"))
+41 -2
View File
@@ -8,9 +8,10 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
from models import ( from models import (
get_all_bids, get_bid, create_bid, update_bid, delete_bid, get_all_bids, get_bid, create_bid, update_bid, delete_bid,
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES, get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
log_action, log_action, get_bids_due_soon, get_admin_emails,
) )
from utils.decorators import login_required from utils.decorators import login_required, admin_required
from utils.email import send_email
logger = logging.getLogger("routes.bid_tracker") logger = logging.getLogger("routes.bid_tracker")
bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids") bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids")
@@ -237,3 +238,41 @@ def delete_update_json(update_id):
except Exception as e: except Exception as e:
logger.error(f"delete_update_json error: {e}") logger.error(f"delete_update_json error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bid_tracker_bp.route("/remind", methods=["POST"])
@admin_required
def send_reminders():
"""Send an email digest of bids due within 7 days to all admin users with emails."""
user = session["user"]
bids = get_bids_due_soon(days=7)
recipients = get_admin_emails()
if not bids:
flash("No active bids due within 7 days.", "info")
return redirect(url_for("bid_tracker.bids_list"))
if not recipients:
flash("No admin email addresses configured. Add them via Admin → Users.", "warning")
return redirect(url_for("bid_tracker.bids_list"))
lines = [f"Bid Deadline Reminder — {len(bids)} bid(s) due within 7 days:\n"]
for b in bids:
due = str(b["due_date"])[:10] if b.get("due_date") else "N/A"
line = f"{b['title']} — Due: {due} — Status: {b['status']}"
if b.get("solicitation_number"):
line += f" — Sol#: {b['solicitation_number']}"
lines.append(line)
lines.append("\nLog in to Website Checker for full details.")
body = "\n".join(lines)
try:
send_email(recipients, "Website Checker — Upcoming Bid Deadlines", body)
log_action(user["id"], "BID_REMIND", "bid_tracker", None,
f"Bid reminder email sent to {len(recipients)} admin(s), {len(bids)} bid(s) listed.")
flash(f"Reminder digest sent to {len(recipients)} recipient(s).", "success")
except Exception as e:
logger.error(f"send_reminders error: {e}")
flash(f"Failed to send email: {e}", "danger")
return redirect(url_for("bid_tracker.bids_list"))
+23
View File
@@ -3,10 +3,13 @@ routes/user_dashboard.py — Regular user: shift check dashboard.
""" """
import logging import logging
import time
import requests as _http_req
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import ( from models import (
get_today_checks, mark_website_checked, unmark_website_checked, get_today_checks, mark_website_checked, unmark_website_checked,
update_check_note, get_user_active_shifts, get_website_credentials, update_check_note, get_user_active_shifts, get_website_credentials,
get_website_url,
) )
from utils.decorators import login_required from utils.decorators import login_required
@@ -91,3 +94,23 @@ def view_credentials(website_id):
except Exception as e: except Exception as e:
logger.error(f"view_credentials error: {e}") logger.error(f"view_credentials error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@user_dashboard_bp.route("/health/<int:website_id>")
@login_required
def health_check(website_id):
"""Server-side HEAD probe for a website's reachability."""
url = get_website_url(website_id)
if not url:
return jsonify({"status": "not_found"}), 404
try:
t0 = time.time()
resp = _http_req.head(url, timeout=6, allow_redirects=True,
headers={"User-Agent": "WebsiteChecker/1.0 (health-check)"})
ms = int((time.time() - t0) * 1000)
ok = resp.status_code < 400
return jsonify({"status": "ok" if ok else "error", "ms": ms})
except _http_req.exceptions.Timeout:
return jsonify({"status": "timeout", "ms": 6000})
except Exception:
return jsonify({"status": "unreachable", "ms": None})
+22
View File
@@ -22,6 +22,28 @@
</div> </div>
</div> </div>
<!-- Missed shifts alert -->
{% if missed %}
<div class="card mt-3" style="border-color:var(--warning-border)">
<div class="card-header" style="background:var(--warning-bg)">
<span class="card-title" style="color:var(--warning)">⚠ Missed Shifts Today</span>
<span class="text-muted text-sm">Users with 0 sites checked so far</span>
</div>
<table class="table table-sm">
<thead><tr><th>Shift</th><th>User</th><th>Sites Checked</th></tr></thead>
<tbody>
{% for m in missed %}
<tr>
<td>{{ m.shift_name }}</td>
<td>{{ m.full_name }}</td>
<td class="text-muted">{{ m.checked_sites }} / {{ m.total_sites }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Per-user completion table --> <!-- Per-user completion table -->
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"> <div class="card-header">
+51
View File
@@ -3,9 +3,17 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Shift Management</h1> <h1 class="page-title">Shift Management</h1>
<div style="display:flex;gap:.5rem">
<button class="btn btn-ghost btn-sm tab-btn active" data-tab="tab-list" data-group="shifts-tabs">☰ List</button>
<button class="btn btn-ghost btn-sm tab-btn" data-tab="tab-calendar" data-group="shifts-tabs">📅 Calendar</button>
<button class="btn btn-primary" onclick="openModal('modal-create-shift')"> New Shift</button> <button class="btn btn-primary" onclick="openModal('modal-create-shift')"> New Shift</button>
</div> </div>
</div>
<div id="shifts-tabs">
<!-- List tab -->
<div class="tab-panel active" id="tab-list">
<div class="card"> <div class="card">
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
@@ -34,6 +42,49 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Calendar tab -->
<div class="tab-panel" id="tab-calendar">
<div class="card">
<div class="card-header"><span class="card-title">Weekly Schedule</span></div>
<div style="overflow-x:auto">
<table class="table">
<thead>
<tr>
<th style="min-width:160px">Shift</th>
{% for label, _ in day_map %}
<th style="text-align:center;min-width:64px">{{ label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for s in shifts if s.is_active %}
<tr>
<td>
<strong>{{ s.name }}</strong>
<div class="text-muted text-sm">{{ s.start_time|hhmm }}{{ s.end_time|hhmm }}</div>
</td>
{% for _, day_val in day_map %}
<td style="text-align:center">
{% if day_val in s.days_of_week %}
<span class="badge badge-success" title="{{ s.name }} runs on this day"></span>
{% else %}
<span class="text-muted" style="font-size:.9rem"></span>
{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">No active shifts.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /shifts-tabs -->
<!-- Create Shift Modal --> <!-- Create Shift Modal -->
<div class="modal-overlay" id="modal-create-shift"> <div class="modal-overlay" id="modal-create-shift">
+5
View File
@@ -22,6 +22,11 @@
</div> </div>
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button> <button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
{% if session['user']['role'] == 'admin' %}
<form method="post" action="{{ url_for('bid_tracker.send_reminders') }}" style="display:inline">
<button class="btn btn-secondary btn-sm" type="submit" title="Email a digest of bids due within 7 days to admins">📧 Remind</button>
</form>
{% endif %}
</div> </div>
<!-- ── Split pane ─────────────────────────────────────────── --> <!-- ── Split pane ─────────────────────────────────────────── -->
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forgot Password — Website Checker</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="login-wrap">
<div class="login-box">
<div class="login-logo">
<span class="brand-icon">🌐</span>
<h1>Website Checker</h1>
<p>Password Reset</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} mb-2">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post" action="{{ url_for('auth.forgot_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email"
autocomplete="email" autofocus required
placeholder="your@email.com">
</div>
<button class="btn btn-primary w-full" type="submit">Send Reset Link</button>
</form>
<p style="text-align:center;margin-top:1rem;font-size:.875rem">
<a href="{{ url_for('auth.login') }}" style="color:var(--accent)">Back to sign in</a>
</p>
</div>
</body>
</html>
+4
View File
@@ -34,6 +34,10 @@
<button class="btn btn-primary" type="submit">Sign In</button> <button class="btn btn-primary" type="submit">Sign In</button>
</form> </form>
<p style="text-align:center;margin-top:1rem;font-size:.875rem">
<a href="{{ url_for('auth.forgot_password') }}" style="color:var(--accent)">Forgot password?</a>
</p>
</div> </div>
</body> </body>
</html> </html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set New Password — Website Checker</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="login-wrap">
<div class="login-box">
<div class="login-logo">
<span class="brand-icon">🌐</span>
<h1>Website Checker</h1>
<p>Set New Password</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} mb-2">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="post" action="{{ url_for('auth.reset_password', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password"
autofocus required minlength="8"
placeholder="Minimum 8 characters">
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password"
required placeholder="Repeat new password">
</div>
<button class="btn btn-primary w-full" type="submit">Set New Password</button>
</form>
<p style="text-align:center;margin-top:1rem;font-size:.875rem">
<a href="{{ url_for('auth.login') }}" style="color:var(--accent)">Back to sign in</a>
</p>
</div>
</body>
</html>
+20 -16
View File
@@ -381,25 +381,29 @@ function esc(str) {
}); });
} }
/* ── Health dots ───────────────────────────────────────────── */ /* ── Health dots (server-side probe) ───────────────────────── */
document.querySelectorAll('.site-card').forEach(function(card) { document.querySelectorAll('.site-card').forEach(function(card) {
var id = card.dataset.id; var id = card.dataset.id;
var url = card.dataset.url;
var dot = document.getElementById('health-' + id); var dot = document.getElementById('health-' + id);
if (!dot || !url) return; if (!dot) return;
var img = new Image(); fetch('/dashboard/health/' + id, {credentials: 'same-origin'})
var t0 = Date.now(); .then(function(r) { return r.json(); })
img.onload = function() { .then(function(d) {
var ms = Date.now() - t0; if (d.status === 'ok') {
dot.style.color = ms > 3000 ? '#d97706' : '#16a34a'; dot.style.color = d.ms > 3000 ? '#d97706' : '#16a34a';
dot.title = ms > 3000 ? 'Slow (' + ms + 'ms)' : 'Reachable (' + ms + 'ms)'; dot.title = 'Reachable (' + d.ms + 'ms)';
}; } else if (d.status === 'timeout') {
img.onerror = function() { dot.style.color = '#d97706';
var ms = Date.now() - t0; dot.title = 'Timeout (>6s)';
dot.style.color = ms < 6000 ? '#d97706' : '#dc2626'; } else {
dot.title = ms < 6000 ? 'Reachable (restricted)' : 'Unreachable'; dot.style.color = '#dc2626';
}; dot.title = 'Unreachable';
img.src = 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(url) + '&t=' + Date.now(); }
})
.catch(function() {
dot.style.color = '#9ca3af';
dot.title = 'Health check failed';
});
}); });
</script> </script>
+57
View File
@@ -0,0 +1,57 @@
"""
utils/email.py SMTP email helper.
Reads server settings from app_settings (email.smtp_*) with .env fallbacks.
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from config import get_setting
logger = logging.getLogger("utils.email")
def send_email(to, subject: str, body_text: str, body_html: str = None) -> None:
"""Send email via configured SMTP. `to` may be a str or list[str]. Raises on failure."""
host = get_setting("email.smtp_host", "").strip()
port = get_setting("email.smtp_port", "587").strip()
user = get_setting("email.smtp_user", "").strip()
passwd = get_setting("email.smtp_password", "").strip()
from_ = get_setting("email.smtp_from", "").strip() or user
if not host:
raise ValueError("SMTP host not configured. Add it via Admin → Settings.")
try:
port = int(port)
except (ValueError, TypeError):
port = 587
recipients = [to] if isinstance(to, str) else list(to)
if not recipients:
raise ValueError("No recipients specified.")
if body_html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
else:
msg = MIMEText(body_text, "plain")
msg["Subject"] = subject
msg["From"] = from_
msg["To"] = ", ".join(recipients)
with smtplib.SMTP(host, port, timeout=15) as smtp:
smtp.ehlo()
try:
smtp.starttls()
smtp.ehlo()
except smtplib.SMTPException:
pass # server may not support STARTTLS
if user and passwd:
smtp.login(user, passwd)
smtp.sendmail(from_, recipients, msg.as_string())
logger.info("Email sent to %s: %r", recipients, subject)