commit d86f1ba09970048a7423a1bd8925dce9ecd34960 Author: jpmvaz Date: Sun Sep 13 19:59:54 2026 +0100 v_1.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..378ea1e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.env +*.md +__pycache__/ +*.pyc +*.pyo +*.pyd +.venv/ +venv/ +.env.local +data/ +*.db +*.sqlite* +.DS_Store +.idea/ +.vscode/ +node_modules/ +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..699da0c --- /dev/null +++ b/.env.example @@ -0,0 +1,79 @@ +# ============================================================================= +# AlertHub Configuration +# All configuration is done here. NEVER commit your real .env to source control. +# ============================================================================= + +# ---- Flask Core ---- +# Generate a strong key: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change-me-to-a-long-random-string-please-generate-with-secrets-module +FLASK_ENV=production + +# ---- Ports ---- +# AlertHub now runs as a SINGLE service on one port. The admin backoffice is +# served on the same port under the /backoffice path (admins only). +FRONTOFFICE_PORT=8080 + +# BACKOFFICE_PORT is no longer used (the backoffice shares the main port under +# /backoffice). It is kept here only so old .env files remain valid; you can +# safely delete it. +# BACKOFFICE_PORT=9001 + +# ---- Gunicorn (optional) ---- +# Keep GUNICORN_WORKERS at 1 so the reminder scheduler runs exactly once. +# Increase GUNICORN_THREADS for more concurrency instead. +GUNICORN_WORKERS=1 +GUNICORN_THREADS=4 + +# ---- Database ---- +DATABASE_PATH=/data/alerthub.db + +# ---- Initial Administrator ---- +# There is NO seeded admin any more. On first launch, open the app and you'll be +# taken to a one-time "Create administrator" screen to set the username & +# password yourself. (The variables below are legacy/no-ops, kept for reference.) +# INITIAL_ADMIN_USERNAME=admin +# INITIAL_ADMIN_EMAIL=admin@example.com +# INITIAL_ADMIN_PASSWORD= + +# ---- Session / Security ---- +# Set SESSION_COOKIE_SECURE=true if you serve via HTTPS (recommended in production) +SESSION_COOKIE_SECURE=false +SESSION_LIFETIME_HOURS=8 +PASSWORD_MIN_LENGTH=10 + +# ---- MFA ---- +# MFA is optional and self-service: any user can enable it from Account → MFA. +MFA_ISSUER_NAME=AlertHub + +# ---- Scheduler ---- +# How often (in minutes) the scheduler checks alerts and dispatches reminder emails +SCHEDULER_INTERVAL_MINUTES=60 + +# ---- Default Reminder Lead Times (in days) ---- +# Comma-separated list. Configurable per-alert later, but these are defaults. +DEFAULT_REMINDER_DAYS=30,14,7,1 + +# ---- SMTP / Outgoing mail (Purelymail defaults) ---- +# Note: these values seed the DB on first run only. After that, edit them in +# the backoffice at /mail-config. +# Purelymail SMTP: smtp.purelymail.com : 465 (SSL/TLS) +# If your client only supports STARTTLS, use port 587 with SMTP_ENCRYPTION=starttls +SMTP_HOST=smtp.purelymail.com +SMTP_PORT=465 +SMTP_USERNAME=you@yourdomain.com +SMTP_PASSWORD= +SMTP_ENCRYPTION=ssl +# Allowed: none | starttls | ssl +SMTP_FROM_ADDRESS=you@yourdomain.com +SMTP_FROM_NAME=AlertHub Notifications + +# ---- IMAP / Sent-folder copy (Purelymail defaults) ---- +# When a mail is sent from the platform, a copy is appended to the mailbox's +# Sent folder over IMAP so it shows up in your normal mail client. +# Purelymail IMAP: imap.purelymail.com : 993 (SSL/TLS) +# Leave IMAP_USERNAME / IMAP_PASSWORD blank to reuse the SMTP credentials. +IMAP_HOST=imap.purelymail.com +IMAP_PORT=993 +IMAP_USERNAME= +IMAP_PASSWORD= +IMAP_SENT_FOLDER=Sent diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bc2f00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +*.pyc +__pycache__/ +.venv/ +venv/ +data/ +*.db +*.sqlite* +.DS_Store +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dedbe96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1.7 +# ---- Build / runtime image ------------------------------------------------- +# python:3.13-slim-trixie is the current stable Debian-based slim variant. +FROM python:3.13-slim-trixie + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Install only what we strictly need (curl for healthchecks). +# Use --no-install-recommends, then immediately purge apt lists to keep +# the image small. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Create an unprivileged user +RUN groupadd --system --gid 10001 alerthub \ + && useradd --system --uid 10001 --gid alerthub --home-dir /app --shell /usr/sbin/nologin alerthub + +WORKDIR /app + +# Install Python dependencies first (better layer cache) +COPY --chown=alerthub:alerthub requirements.txt ./ +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# Copy application sources +COPY --chown=alerthub:alerthub app/ ./app/ +COPY --chown=alerthub:alerthub wsgi.py wsgi_frontoffice.py wsgi_backoffice.py entrypoint.sh ./ + +RUN chmod +x entrypoint.sh \ + && mkdir -p /data \ + && chown -R alerthub:alerthub /app /data + +USER alerthub + +EXPOSE 8080 + +# Start the unified app (frontoffice + /backoffice) on a single port. +ENTRYPOINT ["./entrypoint.sh"] + +HEALTHCHECK --interval=30s --timeout=4s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${FRONTOFFICE_PORT:-8080}/login >/dev/null || exit 1 diff --git a/OLD_VERSIONS/v1.0/alerthub.zip b/OLD_VERSIONS/v1.0/alerthub.zip new file mode 100644 index 0000000..a8503c0 Binary files /dev/null and b/OLD_VERSIONS/v1.0/alerthub.zip differ diff --git a/OLD_VERSIONS/v1.2/alerthub.zip b/OLD_VERSIONS/v1.2/alerthub.zip new file mode 100644 index 0000000..5881d11 Binary files /dev/null and b/OLD_VERSIONS/v1.2/alerthub.zip differ diff --git a/OLD_VERSIONS/v1.3/alerthub.zip b/OLD_VERSIONS/v1.3/alerthub.zip new file mode 100644 index 0000000..2fef457 Binary files /dev/null and b/OLD_VERSIONS/v1.3/alerthub.zip differ diff --git a/OLD_VERSIONS/v1.4/alerthub.zip b/OLD_VERSIONS/v1.4/alerthub.zip new file mode 100644 index 0000000..18dd74c Binary files /dev/null and b/OLD_VERSIONS/v1.4/alerthub.zip differ diff --git a/OLD_VERSIONS/v1.5/alerthub.zip b/OLD_VERSIONS/v1.5/alerthub.zip new file mode 100644 index 0000000..99d9d11 Binary files /dev/null and b/OLD_VERSIONS/v1.5/alerthub.zip differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a779da7 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# AlertHub + +**Version 1.5 — Martinhal IT (Joao Vaz)** + +A Dockerized alert/reminder web application with email notifications, MFA-capable authentication, and role-based access control. Designed for tracking expiration dates (licenses, renewals, certificates, contracts) with live countdown timers and configurable email reminders. + +## Features + +- **First-run admin setup** — On the very first launch (no accounts yet) you're taken to a "Create administrator" screen to set the admin username & password yourself. No seeded/default admin credentials. +- **Single-port architecture** — Everything runs on one port (`:8080`). The frontoffice (alerts + management) is at the root; the admin backoffice (users, mail, settings, audit log, mail log) is served on the **same port** under `/backoffice` and is restricted to admins. +- **Backoffice shortcut** — Admins get a **Backoffice** link in the top bar (and a **Frontoffice** link back) that switches between the two areas. Only visible to admins. +- **Authentication** — Password (bcrypt, 12 rounds) with optional TOTP MFA. **Any user** can self-enable MFA from *Account → MFA*; it is never mandatory. +- **User avatars** — Any user can upload a profile picture (PNG/JPG/GIF/WebP). Images are auto-squared and resized to 256×256 and stored in the database (survives upgrades). Shown in the top bar. +- **RBAC** — Two roles: `user` (view-only) and `admin` (full access) +- **Live countdown timers** — Per-alert ticking timers (days/hours/minutes/seconds) with state-based color coding (warning, critical, expired) +- **Email reminders** — Configurable SMTP + IMAP editable from the backoffice. Per-alert override of reminder lead times. +- **Mail log** — Every message the platform sends is recorded in the backoffice **Mail Log** (recipients, subject, delivery status, whether the Sent-folder copy was saved, and who triggered it). +- **Sent-folder copy (IMAP)** — After each successful send, a copy of the message is appended to the mailbox's **Sent** folder over IMAP, so it appears in your normal mail client. +- **Log export & email** — Both the Audit log and Mail log can be exported to CSV or emailed (as a CSV attachment) to any destination address. +- **Account security** — Lockout after 5 failed attempts (15-minute window), audit log of sensitive actions, security headers (CSP, X-Frame-Options, HSTS-ready) +- **IT-terminal aesthetic** — JetBrains Mono + IBM Plex Sans, signal-green accents, light + dark mode (auto-detect + manual toggle) +- **Consistent footer** — `© 2026 Martinhal IT - Joao Vaz - Version 1.5` on every page except the login screen. +- **Hardened container** — Non-root user, all capabilities dropped, `no-new-privileges`, healthcheck + +## Stack + +Python 3.13 · Flask 3.1 · SQLAlchemy 2 · Flask-Login · Flask-WTF · APScheduler · pyotp · bcrypt · gunicorn · SQLite + +## Quick start + +```bash +# 1. Get the code, then: +cp .env.example .env + +# 2. Edit .env — at minimum, change: +# - SECRET_KEY (use: python -c "import secrets; print(secrets.token_hex(32))") +# - SMTP_* / IMAP_* (your Purelymail credentials) + +# 3. Build and start: +docker compose up -d --build + +# 4. Open: +# App: http://localhost:8080 +# Backoffice: http://localhost:8080/backoffice (admins only) + +# 5. First visit → you'll be sent to the "Create administrator" screen. +# Set the admin username, email, and password there. +``` + +### First-run checklist + +1. Open the app — you'll land on **Create administrator**. Set the admin username, email and password. +2. Sign in with the credentials you just created. +3. (Optional) Go to **Account** → **Set up MFA** (scan the QR code with Google Authenticator, Authy, 1Password, etc.). Any user can do this. +4. (Optional) **Account** → **Profile avatar** → upload a picture. +5. Open the backoffice → **Mail** → confirm the Purelymail SMTP/IMAP settings, fill in the mailbox username/password, and send a test email. Check the **Mail Log** to confirm it sent and that a copy was saved to the mailbox's Sent folder. +6. Backoffice → **Settings** → set the default reminder lead times (e.g. `30,14,7,1` days before expiry). +7. Frontoffice → **Manage Alerts** → add your first alert. + +## Configuration + +All configuration lives in `.env`. The `docker-compose.yml` only references the file via `env_file` — no secrets are baked into the image or compose file. + +| Variable | Purpose | +|---|---| +| `SECRET_KEY` | Flask session signing key (required, generate a new one) | +| `FRONTOFFICE_PORT` | The single host port (default 8080). `BACKOFFICE_PORT` is no longer used. | +| `GUNICORN_WORKERS` / `GUNICORN_THREADS` | Web server sizing. Keep workers at 1 so the reminder scheduler runs once (default 1 worker / 4 threads). | +| `DATABASE_PATH` | SQLite path inside the container (default `/data/alerthub.db`) | +| `SESSION_LIFETIME_HOURS` | Login session length | +| `MAX_LOGIN_ATTEMPTS` / `LOCKOUT_MINUTES` | Brute-force protection | +| `SCHEDULER_INTERVAL_MINUTES` | How often the reminder job runs (default 60) | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_ENCRYPTION` / `SMTP_FROM_ADDRESS` / `SMTP_FROM_NAME` | Initial outgoing-mail config (Purelymail defaults; seeded into DB on first run; admins edit live values via backoffice afterwards) | +| `IMAP_HOST` / `IMAP_PORT` / `IMAP_USERNAME` / `IMAP_PASSWORD` / `IMAP_SENT_FOLDER` | IMAP settings for saving a copy of sent mail to the Sent folder (Purelymail defaults). Leave user/pass blank to reuse the SMTP credentials. | +| `DEFAULT_REMINDER_DAYS` | Default reminder thresholds, comma-separated (e.g. `30,14,7,1`) | + +> **Admin account:** There is no seeded admin. The first launch presents a one-time **Create administrator** screen. The old `INITIAL_ADMIN_*` variables are no longer used. + +> **Note:** `SMTP_*` / `IMAP_*` values seed the database on first launch. After that, the backoffice **Mail** page is the source of truth — edits there persist independently of `.env`. + +### Purelymail mail settings + +Defaults ship pre-configured for Purelymail: + +- **Outgoing (SMTP):** `smtp.purelymail.com`, port **465**, SSL/TLS. (If your client only supports STARTTLS, use port **587** with `SMTP_ENCRYPTION=starttls`.) +- **Incoming (IMAP):** `imap.purelymail.com`, port **993**, SSL/TLS — used to append sent messages to the **Sent** folder. + +Just fill in your mailbox username and password (in `.env` or the backoffice **Mail** page). + +## Upgrading in place (no data loss) + +This build is designed to be **unzipped over the previous version**. On startup the app runs additive migrations: it creates any new tables (e.g. the mail log) and adds any new columns (avatars) to the existing SQLite database in the `/data` volume, without touching your existing users, alerts, settings, or audit history. + +```bash +# from the folder containing your existing docker-compose.yml / .env / app: +unzip -o alerthub.zip # overwrite code in place (keeps .env and the /data volume) +docker compose up -d --build # rebuild & restart; migrations run automatically +``` + +Your customised SMTP settings are preserved on upgrade; the new IMAP keys are seeded with the Purelymail defaults (adjust them in the backoffice **Mail** page). + +## Architecture + +``` +┌──────────── Docker container ────────────┐ +│ │ +│ entrypoint.sh │ +│ └── gunicorn wsgi:application :8080 │ +│ ├── frontoffice (/) │ +│ ├── backoffice (/backoffice) │ +│ └── scheduler (APScheduler) │ +│ │ +│ /data/alerthub.db (named volume) │ +└──────────────────────────────────────────┘ +``` + +Both processes share the same SQLite database (mounted as a Docker named volume). The reminder scheduler runs only inside the frontoffice process to avoid duplicate emails. + +## Security notes + +- Passwords stored as bcrypt hashes (cost 12). +- TOTP secrets stored encrypted at rest is NOT implemented — the secret is stored as-is. If your threat model requires it, mount the DB on an encrypted volume. +- CSP is strict (no inline scripts except the early theme bootstrap which is `nonce`-able if you tighten further). +- Container runs as uid `10001`, drops all Linux capabilities, and uses `no-new-privileges`. +- Brute-force lockout is per-username, not per-IP. + +## Operational tips + +- **Trigger a reminder check manually:** Backoffice → Settings → "Run check now". +- **Reset a user's MFA:** Backoffice → Users → row menu → Reset MFA. +- **Unlock a locked-out user:** Backoffice → Users → row menu → Unlock. +- **Inspect activity:** Backoffice → Audit log (paginated). +- **Reset reminder history for an alert** (e.g. after extending the date): Manage Alerts → row → Reset reminders. + +## Uninstall / data reset + +```bash +docker compose down # stop containers, keep data +docker compose down -v # also remove the alerthub_data volume (deletes DB) +``` + +## License + +Provided as-is for internal use. No warranty. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..46a18fb --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,278 @@ +"""Application factory. + +Builds a single unified Flask app served on ONE port: + - frontoffice at the root: / (View) and /alerts (Add/Edit/Remove) + - backoffice under /backoffice: users, mail config, settings, audit log, + mail log — all restricted to admins. + +Everything shares the same database, models, and login system. The ``variant`` +argument is retained for backwards compatibility with existing WSGI entry +points but no longer changes what is registered — both areas are always +available on the same app. +""" +import os +import logging +from flask import Flask, redirect, url_for, render_template, request, session +from flask_login import LoginManager +from flask_wtf.csrf import CSRFProtect +from sqlalchemy import inspect, text +from sqlalchemy.exc import IntegrityError, OperationalError + +from .config import Config +from .models import db, User, AppSetting +from .scheduler import init_scheduler + +APP_VERSION = "1.5" + +login_manager = LoginManager() +csrf = CSRFProtect() + + +def _setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + +def _run_migrations(app: Flask): + """Lightweight, additive schema migrations so an existing DB can be + upgraded in place (unzip-over-the-top) without any data loss. + + Only ADDs missing tables/columns — never drops or alters existing data. + Every step is idempotent and tolerant of a concurrent process having + already applied it (both gunicorn apps boot against the same file). + """ + with app.app_context(): + # 1. Create any brand-new tables (e.g. mail_log) and missing tables. + try: + db.create_all() + except OperationalError as e: + # Another booting process created a table at the same moment. + app.logger.info("create_all raced (ignored): %s", e) + db.session.rollback() + + # 2. Add any missing columns on the users table. + inspector = inspect(db.engine) + existing_cols = {c["name"] for c in inspector.get_columns("users")} + add_column = { + "avatar_data": "ALTER TABLE users ADD COLUMN avatar_data BLOB", + "avatar_mime": "ALTER TABLE users ADD COLUMN avatar_mime VARCHAR(64)", + } + for col, ddl in add_column.items(): + if col in existing_cols: + continue + try: + with db.engine.begin() as conn: + conn.execute(text(ddl)) + app.logger.info("Migration: added users.%s", col) + except OperationalError as e: + # "duplicate column name" — another process won the race. Fine. + app.logger.info("Add column %s raced (ignored): %s", col, e) + + +def _seed_settings(app: Flask): + """Seed default settings (mail config, IMAP, reminders) when rows are + missing. Never overwrites values an admin has already set — so upgrades + keep existing configuration. New keys (e.g. IMAP) are seeded on upgrade. + + Uses an INSERT that tolerates a concurrent process seeding the same key. + """ + with app.app_context(): + defaults = { + # ---- SMTP (outgoing) — Purelymail defaults ---- + "smtp_host": app.config.get("SMTP_HOST", "smtp.purelymail.com"), + "smtp_port": str(app.config.get("SMTP_PORT", 465)), + "smtp_username": app.config.get("SMTP_USERNAME", ""), + "smtp_password": app.config.get("SMTP_PASSWORD", ""), + "smtp_encryption": app.config.get("SMTP_ENCRYPTION", "ssl"), + "smtp_from_address": app.config.get("SMTP_FROM_ADDRESS", ""), + "smtp_from_name": app.config.get("SMTP_FROM_NAME", "AlertHub"), + # ---- IMAP (Sent-folder copy) — Purelymail defaults ---- + "imap_enabled": "true", + "imap_host": app.config.get("IMAP_HOST", "imap.purelymail.com"), + "imap_port": str(app.config.get("IMAP_PORT", 993)), + "imap_username": app.config.get("IMAP_USERNAME", ""), + "imap_password": app.config.get("IMAP_PASSWORD", ""), + "imap_sent_folder": app.config.get("IMAP_SENT_FOLDER", "Sent"), + # ---- Reminders ---- + "default_reminder_days": app.config.get("DEFAULT_REMINDER_DAYS", "30,14,7,1"), + } + for k, v in defaults.items(): + if AppSetting.get(k) is not None: + continue + try: + row = AppSetting(key=k, value=v) + db.session.add(row) + db.session.commit() + except IntegrityError: + # A concurrent boot inserted this key first — that's fine. + db.session.rollback() + + +def _initialise_database(app: Flask): + """Run migrations + seeding exactly once at a time across processes. + + The container starts several worker processes that all import the app and + hit the shared SQLite file simultaneously. A cross-process file lock + serialises initialisation so concurrent DDL/seeding can't collide. + """ + import fcntl # POSIX-only; the container is Linux. + + db_dir = os.path.dirname(app.config["DATABASE_PATH"]) or "." + os.makedirs(db_dir, exist_ok=True) + lock_path = os.path.join(db_dir, ".alerthub-init.lock") + + try: + lock_file = open(lock_path, "w") + except OSError: + # If we somehow can't open the lock file, fall back to unlocked init; + # the per-step tolerance above still guards against most races. + _run_migrations(app) + _seed_settings(app) + return + + with lock_file: + try: + fcntl.flock(lock_file, fcntl.LOCK_EX) + except OSError: + pass # advisory lock unavailable — proceed; steps are tolerant. + try: + _run_migrations(app) + _seed_settings(app) + finally: + try: + fcntl.flock(lock_file, fcntl.LOCK_UN) + except OSError: + pass + + +def create_app(variant: str = "frontoffice") -> Flask: + """Build the unified Flask app (frontoffice + /backoffice on one port). + + ``variant`` is accepted for backwards compatibility with the existing WSGI + entry points but no longer restricts which blueprints are registered. + """ + if variant not in ("frontoffice", "backoffice"): + variant = "frontoffice" + + _setup_logging() + + app = Flask( + __name__, + template_folder="templates", + static_folder="static", + ) + app.config.from_object(Config) + app.config["APP_VARIANT"] = variant + + # Make sure data dir exists + os.makedirs(os.path.dirname(app.config["DATABASE_PATH"]), exist_ok=True) + + db.init_app(app) + csrf.init_app(app) + login_manager.init_app(app) + login_manager.login_view = "auth.login" + login_manager.login_message = "Please sign in to continue." + login_manager.login_message_category = "info" + + @login_manager.user_loader + def load_user(user_id): + try: + return db.session.get(User, int(user_id)) + except (TypeError, ValueError): + return None + + # ---- Blueprints ---- + # Unified app: the frontoffice lives at the root and the backoffice is + # mounted under /backoffice, so both are served on the SAME port. Access to + # backoffice views is still restricted to admins by @admin_required. + from .routes_auth import auth_bp + from .routes_main import main_bp + from .routes_backoffice import backoffice_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.register_blueprint(backoffice_bp, url_prefix="/backoffice") + + # ---- Error handlers ---- + @app.errorhandler(403) + def forbidden(_): + return render_template("error.html", code=403, message="You don't have permission to view this page."), 403 + + @app.errorhandler(404) + def not_found(_): + return render_template("error.html", code=404, message="Page not found."), 404 + + @app.errorhandler(500) + def server_error(_): + return render_template("error.html", code=500, message="Internal server error."), 500 + + # ---- Security headers ---- + @app.after_request + def add_security_headers(response): + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + # Reasonable CSP for our self-hosted assets + the QR data URI + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; " + "style-src 'self' 'unsafe-inline'; script-src 'self'; " + "font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + ) + return response + + # ---- Template context: brand + which area we're in ---- + @app.context_processor + def inject_globals(): + # The backoffice is now mounted under /backoffice on the same origin, + # so cross-links are simple same-site URLs and "is_backoffice" is + # determined by which blueprint served the current request. + is_bo = (request.blueprint == "backoffice") if request else False + try: + backoffice_url = url_for("backoffice.dashboard") + frontoffice_url = url_for("main.index") + except Exception: + backoffice_url = "/backoffice/" + frontoffice_url = "/" + return { + "app_variant": "backoffice" if is_bo else "frontoffice", + "brand_name": "AlertHub", + "is_backoffice": is_bo, + "app_version": APP_VERSION, + "footer_text": "© 2026 Martinhal IT - Joao Vaz - Version " + APP_VERSION, + "backoffice_url": backoffice_url, + "frontoffice_url": frontoffice_url, + } + + # ---- Initialise DB (additive migrations) + seed default settings. + # Serialised across the container's worker processes via a file lock. ---- + _initialise_database(app) + + # ---- First-run guard: if there are no users yet, force the operator + # through the "create administrator" setup screen before anything else. ---- + app._alerthub_has_users = False + + @app.before_request + def _require_initial_setup(): + if app._alerthub_has_users: + return None + # Allow static assets and the setup endpoint itself through. + if request.endpoint in ("static", "auth.setup"): + return None + try: + if User.query.count() > 0: + app._alerthub_has_users = True + return None + except Exception: + return None + return redirect(url_for("auth.setup")) + + # ---- Scheduler. init_scheduler() is idempotent (guarded by a module-level + # singleton), and the container runs a single Gunicorn worker, so exactly + # one reminder scheduler runs regardless of how many times this is called. ---- + init_scheduler(app) + + return app diff --git a/app/auth_utils.py b/app/auth_utils.py new file mode 100644 index 0000000..ecdf567 --- /dev/null +++ b/app/auth_utils.py @@ -0,0 +1,37 @@ +"""Auth helpers and decorators.""" +from functools import wraps +from flask import abort, redirect, url_for, session, flash, request +from flask_login import current_user + + +def admin_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for("auth.login", next=request.full_path)) + if not current_user.is_admin: + abort(403) + if session.get("mfa_pending"): + return redirect(url_for("auth.mfa_challenge")) + return view(*args, **kwargs) + return wrapped + + +def login_required_full(view): + """Like flask_login.login_required, but also blocks users with pending MFA.""" + @wraps(view) + def wrapped(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for("auth.login", next=request.full_path)) + if session.get("mfa_pending"): + return redirect(url_for("auth.mfa_challenge")) + return view(*args, **kwargs) + return wrapped + + +def get_remote_ip() -> str: + """Best-effort IP detection for audit logs.""" + fwd = request.headers.get("X-Forwarded-For", "") + if fwd: + return fwd.split(",")[0].strip() + return request.remote_addr or "" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ffa9a56 --- /dev/null +++ b/app/config.py @@ -0,0 +1,81 @@ +"""Configuration loaded from environment variables.""" +import os +from datetime import timedelta + + +def _bool(val: str, default: bool = False) -> bool: + if val is None: + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-replace-me") + FLASK_ENV = os.environ.get("FLASK_ENV", "production") + + DATABASE_PATH = os.environ.get("DATABASE_PATH", "/data/alerthub.db") + SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_PATH}" + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + # Wait (instead of erroring) when another process holds the SQLite + # write lock — both gunicorn apps share one database file. + "connect_args": {"timeout": 30}, + } + + # Bootstrap admin (only used on first DB init) + INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME", "admin") + INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL", "admin@example.com") + INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD", "ChangeMeOnFirstLogin!") + + # Session security + SESSION_COOKIE_SECURE = _bool(os.environ.get("SESSION_COOKIE_SECURE"), False) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + PERMANENT_SESSION_LIFETIME = timedelta( + hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "8")) + ) + + PASSWORD_MIN_LENGTH = int(os.environ.get("PASSWORD_MIN_LENGTH", "10")) + + # MFA + MFA_ISSUER_NAME = os.environ.get("MFA_ISSUER_NAME", "AlertHub") + + # Scheduler + SCHEDULER_INTERVAL_MINUTES = int(os.environ.get("SCHEDULER_INTERVAL_MINUTES", "60")) + + # Default reminder lead times in days + DEFAULT_REMINDER_DAYS = os.environ.get("DEFAULT_REMINDER_DAYS", "30,14,7,1") + + # SMTP defaults (seed only — admins edit live values via backoffice) + # Purelymail outgoing: smtp.purelymail.com : 465 (SSL/TLS) + SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.purelymail.com") + SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) + SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "") + SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") + SMTP_ENCRYPTION = os.environ.get("SMTP_ENCRYPTION", "ssl") + SMTP_FROM_ADDRESS = os.environ.get("SMTP_FROM_ADDRESS", "") + SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AlertHub") + + # IMAP defaults (seed only) — used to save a copy of sent mail to the + # mailbox's Sent folder. Purelymail incoming: imap.purelymail.com : 993 (SSL/TLS) + IMAP_HOST = os.environ.get("IMAP_HOST", "imap.purelymail.com") + IMAP_PORT = int(os.environ.get("IMAP_PORT", "993")) + IMAP_USERNAME = os.environ.get("IMAP_USERNAME", "") + IMAP_PASSWORD = os.environ.get("IMAP_PASSWORD", "") + IMAP_SENT_FOLDER = os.environ.get("IMAP_SENT_FOLDER", "Sent") + + # Ports (used by run scripts only) + FRONTOFFICE_PORT = int(os.environ.get("FRONTOFFICE_PORT", "8080")) + BACKOFFICE_PORT = int(os.environ.get("BACKOFFICE_PORT", "9001")) + + # Optional explicit public URLs for the cross-console links in the top bar. + # If empty, the app derives them from the request host + the ports above + # (e.g. http://:9001/). Set these when behind a reverse proxy + # or when the consoles are reached at custom hostnames/paths. + BACKOFFICE_PUBLIC_URL = os.environ.get("BACKOFFICE_PUBLIC_URL", "") + FRONTOFFICE_PUBLIC_URL = os.environ.get("FRONTOFFICE_PUBLIC_URL", "") + + # Resource limits + MAX_CONTENT_LENGTH = 2 * 1024 * 1024 # 2MB upload cap + WTF_CSRF_TIME_LIMIT = 3600 diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..0947d0e --- /dev/null +++ b/app/forms.py @@ -0,0 +1,139 @@ +"""WTForms — all forms get CSRF protection automatically via Flask-WTF.""" +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed, FileRequired +from wtforms import StringField, PasswordField, BooleanField, IntegerField, SelectField, DateField, TextAreaField, SubmitField +from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional, NumberRange, Regexp + + +class LoginForm(FlaskForm): + username = StringField("Username", validators=[DataRequired(), Length(min=2, max=64)]) + password = PasswordField("Password", validators=[DataRequired(), Length(min=1, max=200)]) + submit = SubmitField("Sign in") + + +class SetupAdminForm(FlaskForm): + """First-run form: create the very first administrator account.""" + username = StringField("Admin username", validators=[ + DataRequired(), + Length(min=2, max=64), + Regexp(r"^[A-Za-z0-9_.\-]+$", message="Letters, digits, underscore, dot, dash only."), + ]) + email = StringField("Admin email", validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField("Password", validators=[ + DataRequired(), + Length(min=10, max=200, message="Min 10 characters"), + ]) + confirm = PasswordField("Confirm password", validators=[ + DataRequired(), + EqualTo("password", message="Passwords must match."), + ]) + submit = SubmitField("Create administrator") + + +class AvatarForm(FlaskForm): + avatar = FileField("Avatar image", validators=[ + FileRequired(message="Choose an image file."), + FileAllowed(["png", "jpg", "jpeg", "gif", "webp"], "Images only (png, jpg, gif, webp)."), + ]) + submit = SubmitField("Upload avatar") + + +class MfaForm(FlaskForm): + code = StringField("Authentication code", validators=[ + DataRequired(), + Length(min=6, max=6, message="Must be 6 digits"), + Regexp(r"^\d{6}$", message="Digits only"), + ]) + submit = SubmitField("Verify") + + +class MfaSetupForm(FlaskForm): + code = StringField("Verification code", validators=[ + DataRequired(), + Length(min=6, max=6), + Regexp(r"^\d{6}$"), + ]) + submit = SubmitField("Enable MFA") + + +class ChangePasswordForm(FlaskForm): + current_password = PasswordField("Current password", validators=[DataRequired()]) + new_password = PasswordField("New password", validators=[ + DataRequired(), + Length(min=10, max=200, message="Min 10 characters"), + ]) + confirm = PasswordField("Confirm new password", validators=[ + DataRequired(), + EqualTo("new_password", message="Passwords must match."), + ]) + submit = SubmitField("Change password") + + +class AlertForm(FlaskForm): + title = StringField("Title", validators=[DataRequired(), Length(max=200)]) + category = StringField("Category", validators=[Optional(), Length(max=64)]) + description = TextAreaField("Description", validators=[Optional(), Length(max=5000)]) + expiration_date = DateField("Expiration date", validators=[DataRequired()], format="%Y-%m-%d") + reminder_days = StringField( + "Reminder days (comma-separated, days before expiration)", + validators=[DataRequired(), Regexp(r"^[\d,\s]+$", message="Numbers separated by commas only")], + default="30,14,7,1", + ) + is_active = BooleanField("Active", default=True) + submit = SubmitField("Save") + + +class UserForm(FlaskForm): + username = StringField("Username", validators=[ + DataRequired(), + Length(min=2, max=64), + Regexp(r"^[A-Za-z0-9_.\-]+$", message="Letters, digits, underscore, dot, dash only."), + ]) + email = StringField("Email", validators=[DataRequired(), Email(), Length(max=255)]) + role = SelectField("Role", choices=[("user", "User (view only)"), ("admin", "Administrator")]) + is_active = BooleanField("Active", default=True) + password = PasswordField("Password (leave blank to keep current)", validators=[Optional(), Length(min=10, max=200)]) + submit = SubmitField("Save") + + +class MailConfigForm(FlaskForm): + smtp_host = StringField("SMTP Host", validators=[DataRequired(), Length(max=255)]) + smtp_port = IntegerField("Port", validators=[DataRequired(), NumberRange(min=1, max=65535)]) + smtp_username = StringField("Username", validators=[Optional(), Length(max=255)]) + smtp_password = PasswordField("Password (leave blank to keep current)", validators=[Optional(), Length(max=255)]) + smtp_encryption = SelectField("Encryption", choices=[ + ("none", "None (plain — not recommended)"), + ("starttls", "STARTTLS (port 587)"), + ("ssl", "SSL/TLS (port 465)"), + ]) + smtp_from_address = StringField("From address", validators=[DataRequired(), Email(), Length(max=255)]) + smtp_from_name = StringField("From name", validators=[Optional(), Length(max=255)]) + + # ---- IMAP: used to save a copy of every sent message to the Sent folder ---- + imap_enabled = BooleanField("Save a copy of sent mail to the mailbox (IMAP)", default=True) + imap_host = StringField("IMAP Host", validators=[Optional(), Length(max=255)]) + imap_port = IntegerField("IMAP Port", validators=[Optional(), NumberRange(min=1, max=65535)]) + imap_username = StringField("IMAP Username (blank = use SMTP username)", validators=[Optional(), Length(max=255)]) + imap_password = PasswordField("IMAP Password (blank = keep current / use SMTP password)", validators=[Optional(), Length(max=255)]) + imap_sent_folder = StringField("Sent folder name", validators=[Optional(), Length(max=128)]) + + submit = SubmitField("Save mail configuration") + + +class TestMailForm(FlaskForm): + test_recipient = StringField("Send test email to", validators=[DataRequired(), Email()]) + submit = SubmitField("Send test email") + + +class LogEmailForm(FlaskForm): + """Email an exported log (CSV attachment) to a destination address.""" + log_recipient = StringField("Email log to", validators=[DataRequired(), Email()]) + submit = SubmitField("Email log") + + +class GlobalSettingsForm(FlaskForm): + default_reminder_days = StringField( + "Default reminder days (comma-separated)", + validators=[DataRequired(), Regexp(r"^[\d,\s]+$")], + ) + submit = SubmitField("Save settings") diff --git a/app/mailer.py b/app/mailer.py new file mode 100644 index 0000000..d1edfa5 --- /dev/null +++ b/app/mailer.py @@ -0,0 +1,161 @@ +"""SMTP mailer that pulls live config from the AppSetting store. + +Every send is recorded in the MailLog (visible in the backoffice "Mail Log"), +and — when IMAP is configured — a copy of the outgoing message is appended to +the external mailbox's Sent folder so it shows up in normal mail clients. +""" +import smtplib +import imaplib +import ssl +import time +import logging +from email.message import EmailMessage +from email.utils import formataddr, formatdate +from flask_login import current_user +from .models import AppSetting, MailLog + +logger = logging.getLogger(__name__) + + +def get_mail_config() -> dict: + """Read SMTP + IMAP settings from the database.""" + return { + # ---- SMTP (outgoing) ---- + "host": AppSetting.get("smtp_host", "") or "", + "port": int(AppSetting.get("smtp_port", "465") or "465"), + "username": AppSetting.get("smtp_username", "") or "", + "password": AppSetting.get("smtp_password", "") or "", + "encryption": (AppSetting.get("smtp_encryption", "ssl") or "ssl").lower(), + "from_address": AppSetting.get("smtp_from_address", "") or "", + "from_name": AppSetting.get("smtp_from_name", "AlertHub") or "AlertHub", + # ---- IMAP (for saving a copy in the Sent folder) ---- + "imap_enabled": (AppSetting.get("imap_enabled", "true") or "true").lower() in ("1", "true", "yes", "on"), + "imap_host": AppSetting.get("imap_host", "") or "", + "imap_port": int(AppSetting.get("imap_port", "993") or "993"), + "imap_username": AppSetting.get("imap_username", "") or "", + "imap_password": AppSetting.get("imap_password", "") or "", + "imap_sent_folder": AppSetting.get("imap_sent_folder", "Sent") or "Sent", + } + + +def _build_message(cfg, to_addresses, subject, body_text, body_html=None, attachments=None) -> EmailMessage: + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = formataddr((cfg["from_name"], cfg["from_address"])) + msg["To"] = ", ".join(to_addresses) + msg["Date"] = formatdate(localtime=True) + msg.set_content(body_text) + if body_html: + msg.add_alternative(body_html, subtype="html") + for att in (attachments or []): + # att = (filename, mime_maintype, mime_subtype, bytes) + fname, maintype, subtype, data = att + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=fname) + return msg + + +def _append_to_sent(cfg, msg) -> tuple[bool, str]: + """Append a copy of the message to the mailbox's Sent folder over IMAP.""" + if not cfg.get("imap_enabled"): + return False, "disabled" + imap_host = cfg["imap_host"] or "" + imap_user = cfg["imap_username"] or cfg["username"] + imap_pass = cfg["imap_password"] or cfg["password"] + if not imap_host or not imap_user or not imap_pass: + return False, "IMAP not fully configured" + folder = cfg.get("imap_sent_folder") or "Sent" + try: + context = ssl.create_default_context() + with imaplib.IMAP4_SSL(imap_host, cfg.get("imap_port", 993), ssl_context=context, timeout=20) as imap: + imap.login(imap_user, imap_pass) + raw = msg.as_bytes() + # \Seen so it doesn't show as unread in the Sent folder + typ, _ = imap.append(folder, "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw) + if typ != "OK": + return False, f"IMAP append returned {typ}" + return True, "saved" + except (imaplib.IMAP4.error, ssl.SSLError, OSError) as e: + logger.exception("IMAP append to Sent failed") + return False, f"IMAP error: {e}" + + +def _smtp_send(cfg, msg) -> tuple[bool, str]: + encryption = cfg["encryption"] + try: + if encryption == "ssl": + context = ssl.create_default_context() + with smtplib.SMTP_SSL(cfg["host"], cfg["port"], context=context, timeout=20) as server: + if cfg["username"]: + server.login(cfg["username"], cfg["password"]) + server.send_message(msg) + else: + with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server: + server.ehlo() + if encryption == "starttls": + context = ssl.create_default_context() + server.starttls(context=context) + server.ehlo() + if cfg["username"]: + server.login(cfg["username"], cfg["password"]) + server.send_message(msg) + return True, "ok" + except smtplib.SMTPAuthenticationError as e: + logger.exception("SMTP auth failed") + err = e.smtp_error.decode("utf-8", errors="ignore") if isinstance(e.smtp_error, bytes) else e.smtp_error + return False, f"Authentication failed: {e.smtp_code} {err}" + except (smtplib.SMTPException, ssl.SSLError, OSError) as e: + logger.exception("SMTP error") + return False, f"SMTP error: {e}" + + +def send_mail(to_addresses, subject: str, body_text: str, body_html: str | None = None, + attachments=None, kind: str = "general", triggered_by: str | None = None) -> tuple[bool, str]: + """Send an email. Returns (success, message). Never raises. + + Side effects: + - Appends a copy to the external mailbox Sent folder (IMAP) on success. + - Records the send (and Sent-copy result) in the MailLog. + """ + cfg = get_mail_config() + + # Resolve who triggered this (username) for the log. + if triggered_by is None: + try: + triggered_by = current_user.username if current_user.is_authenticated else "system" + except Exception: + triggered_by = "system" + + if not cfg["host"] or not cfg["from_address"]: + info = "SMTP not configured (host or from-address missing)." + MailLog.record(kind, to_addresses, subject, "failed", info, "n/a", triggered_by) + return False, info + + if isinstance(to_addresses, str): + to_addresses = [to_addresses] + to_addresses = [a for a in to_addresses if a] + if not to_addresses: + info = "No recipients." + MailLog.record(kind, "", subject, "failed", info, "n/a", triggered_by) + return False, info + + msg = _build_message(cfg, to_addresses, subject, body_text, body_html, attachments) + + ok, info = _smtp_send(cfg, msg) + + sent_copy = "n/a" + if ok: + copied, copy_info = _append_to_sent(cfg, msg) + if copy_info == "disabled": + sent_copy = "disabled" + elif copied: + sent_copy = "saved" + else: + sent_copy = "failed" + info = f"{info}; Sent-copy: {copy_info}" + + MailLog.record(kind, to_addresses, subject, + "sent" if ok else "failed", info, sent_copy, triggered_by) + + if ok: + return True, f"Sent to {len(to_addresses)} recipient(s)." + return False, info diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..b125171 --- /dev/null +++ b/app/models.py @@ -0,0 +1,192 @@ +"""SQLAlchemy models.""" +from datetime import datetime, timezone +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +import bcrypt +import json + +db = SQLAlchemy() + + +def utcnow(): + return datetime.now(timezone.utc) + + +class User(UserMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False, index=True) + email = db.Column(db.String(255), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(16), nullable=False, default="user") # 'user' | 'admin' + is_active = db.Column(db.Boolean, default=True, nullable=False) + + mfa_secret = db.Column(db.String(64), nullable=True) + mfa_enabled = db.Column(db.Boolean, default=False, nullable=False) + + # Optional user avatar (stored as normalised PNG bytes in the DB so it + # survives an "unzip over the top" upgrade without any external files). + avatar_data = db.Column(db.LargeBinary, nullable=True) + avatar_mime = db.Column(db.String(64), nullable=True) + + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + last_login_at = db.Column(db.DateTime, nullable=True) + failed_login_count = db.Column(db.Integer, default=0, nullable=False) + locked_until = db.Column(db.DateTime, nullable=True) + + # ---- password helpers ---- + def set_password(self, password: str) -> None: + self.password_hash = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt(rounds=12) + ).decode("utf-8") + + def check_password(self, password: str) -> bool: + try: + return bcrypt.checkpw( + password.encode("utf-8"), self.password_hash.encode("utf-8") + ) + except (ValueError, TypeError): + return False + + @property + def is_admin(self) -> bool: + return self.role == "admin" + + @property + def is_locked(self) -> bool: + return self.locked_until is not None and self.locked_until > utcnow() + + @property + def has_avatar(self) -> bool: + return self.avatar_data is not None and len(self.avatar_data) > 0 + + @property + def initials(self) -> str: + return (self.username or "?")[:2].upper() + + +class Alert(db.Model): + __tablename__ = "alerts" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + category = db.Column(db.String(64), nullable=True) # e.g. 'License', 'Renewal' + expiration_date = db.Column(db.Date, nullable=False) + # JSON-encoded list of integer days, e.g. "[30, 14, 7, 1]" + reminder_days_json = db.Column(db.String(255), nullable=False, default="[30,14,7,1]") + # JSON-encoded list of days for which the reminder has already been sent (avoid dupes) + reminders_sent_json = db.Column(db.String(255), nullable=False, default="[]") + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + creator = db.relationship("User", foreign_keys=[created_by]) + + @property + def reminder_days(self) -> list: + try: + return sorted(set(int(x) for x in json.loads(self.reminder_days_json)), reverse=True) + except (ValueError, TypeError): + return [] + + @reminder_days.setter + def reminder_days(self, value) -> None: + self.reminder_days_json = json.dumps(sorted(set(int(x) for x in value), reverse=True)) + + @property + def reminders_sent(self) -> list: + try: + return list(json.loads(self.reminders_sent_json)) + except (ValueError, TypeError): + return [] + + @reminders_sent.setter + def reminders_sent(self, value) -> None: + self.reminders_sent_json = json.dumps(list(value)) + + +class AppSetting(db.Model): + """Generic key/value settings store (mail config + misc).""" + __tablename__ = "app_settings" + + key = db.Column(db.String(64), primary_key=True) + value = db.Column(db.Text, nullable=True) + updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + @classmethod + def get(cls, key: str, default=None): + row = db.session.get(cls, key) + return row.value if row is not None else default + + @classmethod + def set(cls, key: str, value: str) -> None: + row = db.session.get(cls, key) + if row is None: + row = cls(key=key, value=value) + db.session.add(row) + else: + row.value = value + db.session.commit() + + @classmethod + def get_all(cls) -> dict: + return {row.key: row.value for row in cls.query.all()} + + +class AuditLog(db.Model): + __tablename__ = "audit_log" + + id = db.Column(db.Integer, primary_key=True) + timestamp = db.Column(db.DateTime, default=utcnow, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + username = db.Column(db.String(64), nullable=True) # denormalised for survival after delete + action = db.Column(db.String(64), nullable=False) + details = db.Column(db.Text, nullable=True) + ip_address = db.Column(db.String(45), nullable=True) + + @staticmethod + def record(action: str, user=None, details: str = "", ip: str = ""): + entry = AuditLog( + action=action, + user_id=getattr(user, "id", None), + username=getattr(user, "username", None), + details=details[:1000] if details else None, + ip_address=ip, + ) + db.session.add(entry) + db.session.commit() + + +class MailLog(db.Model): + """Record of every outbound email dispatched by the platform.""" + __tablename__ = "mail_log" + + id = db.Column(db.Integer, primary_key=True) + timestamp = db.Column(db.DateTime, default=utcnow, nullable=False, index=True) + kind = db.Column(db.String(32), nullable=False, default="general") # reminder | test | log-export | general + recipients = db.Column(db.Text, nullable=True) + subject = db.Column(db.String(500), nullable=True) + status = db.Column(db.String(16), nullable=False, default="unknown") # sent | failed + info = db.Column(db.Text, nullable=True) + sent_copy = db.Column(db.String(24), nullable=False, default="n/a") # saved | failed | disabled | n/a + triggered_by = db.Column(db.String(64), nullable=True) # username or "system" + + @staticmethod + def record(kind, recipients, subject, status, info="", sent_copy="n/a", triggered_by="system"): + if isinstance(recipients, (list, tuple)): + recipients = ", ".join(str(r) for r in recipients) + entry = MailLog( + kind=(kind or "general")[:32], + recipients=(recipients or "")[:2000], + subject=(subject or "")[:500], + status=(status or "unknown")[:16], + info=(info or "")[:2000], + sent_copy=(sent_copy or "n/a")[:24], + triggered_by=(triggered_by or "system")[:64], + ) + db.session.add(entry) + db.session.commit() + return entry diff --git a/app/routes_auth.py b/app/routes_auth.py new file mode 100644 index 0000000..5a094c0 --- /dev/null +++ b/app/routes_auth.py @@ -0,0 +1,308 @@ +"""Authentication routes: first-run setup, login, MFA, logout, password, avatar.""" +import io +import base64 +from datetime import timedelta +import pyotp +import qrcode +from PIL import Image, ImageOps +from flask import ( + Blueprint, render_template, redirect, url_for, flash, request, session, + current_app, abort, Response, +) +from flask_login import login_user, logout_user, login_required, current_user + +from .models import db, User, AuditLog, utcnow +from .forms import LoginForm, MfaForm, MfaSetupForm, ChangePasswordForm, SetupAdminForm, AvatarForm +from .auth_utils import get_remote_ip, login_required_full + +auth_bp = Blueprint("auth", __name__) + + +AVATAR_SIZE = 256 + + +def _normalise_avatar(file_storage) -> bytes: + """Open an uploaded image, square-crop + resize, return PNG bytes. + + Raises ValueError if the file is not a usable image. + """ + try: + img = Image.open(file_storage.stream) + img = ImageOps.exif_transpose(img) + img = img.convert("RGBA") if img.mode in ("RGBA", "LA", "P") else img.convert("RGB") + # Center-crop to a square, then resize. + img = ImageOps.fit(img, (AVATAR_SIZE, AVATAR_SIZE), method=Image.LANCZOS) + if img.mode == "RGBA": + background = Image.new("RGBA", img.size, (255, 255, 255, 0)) + img = Image.alpha_composite(background, img) + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return buf.getvalue() + except Exception as exc: # noqa: BLE001 - surface a clean error to the user + raise ValueError(f"Could not process image: {exc}") + + +# --------------------------------------------------------------------------- # +# First-run setup — create the initial administrator +# --------------------------------------------------------------------------- # +@auth_bp.route("/setup", methods=["GET", "POST"]) +def setup(): + # Only available while there are zero users. Once an admin exists, hide it. + if User.query.count() > 0: + return redirect(url_for("auth.login")) + + form = SetupAdminForm() + if form.validate_on_submit(): + admin = User( + username=form.username.data.strip(), + email=form.email.data.strip().lower(), + role="admin", + is_active=True, + ) + admin.set_password(form.password.data) + db.session.add(admin) + db.session.commit() + current_app._alerthub_has_users = True + AuditLog.record("setup.admin_created", user=admin, details=f"username={admin.username}", ip=get_remote_ip()) + flash("Administrator account created. Please sign in.", "success") + return redirect(url_for("auth.login")) + + return render_template("setup.html", form=form) + + +# --------------------------------------------------------------------------- # +# Login +# --------------------------------------------------------------------------- # +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated and not session.get("mfa_pending"): + return redirect(url_for("main.index")) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data.strip()).first() + ip = get_remote_ip() + + if user is None or not user.check_password(form.password.data): + # Increment failed login if user exists + if user is not None: + user.failed_login_count = (user.failed_login_count or 0) + 1 + if user.failed_login_count >= 5: + user.locked_until = utcnow() + timedelta(minutes=15) + user.failed_login_count = 0 + AuditLog.record("login.lockout", user=user, details=f"5 failed attempts", ip=ip) + db.session.commit() + AuditLog.record("login.fail", details=f"username={form.username.data}", ip=ip) + flash("Invalid credentials.", "error") + return render_template("login.html", form=form), 401 + + if not user.is_active: + flash("Account is disabled.", "error") + AuditLog.record("login.disabled", user=user, ip=ip) + return render_template("login.html", form=form), 403 + + if user.is_locked: + flash("Account is temporarily locked due to failed login attempts. Try again later.", "error") + AuditLog.record("login.locked", user=user, ip=ip) + return render_template("login.html", form=form), 423 + + # Password OK — reset counters + user.failed_login_count = 0 + user.locked_until = None + + if user.mfa_enabled: + # Stage 1 success — but require MFA before granting full session + session["mfa_pending"] = True + session["pending_user_id"] = user.id + db.session.commit() + AuditLog.record("login.password_ok", user=user, ip=ip) + return redirect(url_for("auth.mfa_challenge")) + + # No MFA — full login + login_user(user, remember=False) + session.permanent = True + session.pop("mfa_pending", None) + session.pop("pending_user_id", None) + user.last_login_at = utcnow() + db.session.commit() + AuditLog.record("login.success", user=user, ip=ip) + + next_url = request.args.get("next") or url_for("main.index") + return redirect(next_url) + + return render_template("login.html", form=form) + + +# --------------------------------------------------------------------------- # +# MFA challenge (after password OK) +# --------------------------------------------------------------------------- # +@auth_bp.route("/mfa", methods=["GET", "POST"]) +def mfa_challenge(): + if not session.get("mfa_pending"): + return redirect(url_for("auth.login")) + + user_id = session.get("pending_user_id") + user = db.session.get(User, user_id) if user_id else None + if user is None or not user.mfa_enabled or not user.mfa_secret: + session.pop("mfa_pending", None) + session.pop("pending_user_id", None) + return redirect(url_for("auth.login")) + + form = MfaForm() + if form.validate_on_submit(): + totp = pyotp.TOTP(user.mfa_secret) + if totp.verify(form.code.data, valid_window=1): + login_user(user, remember=False) + session.permanent = True + session.pop("mfa_pending", None) + session.pop("pending_user_id", None) + user.last_login_at = utcnow() + db.session.commit() + AuditLog.record("login.mfa_ok", user=user, ip=get_remote_ip()) + return redirect(url_for("main.index")) + flash("Invalid authentication code.", "error") + AuditLog.record("login.mfa_fail", user=user, ip=get_remote_ip()) + + return render_template("mfa.html", form=form) + + +# --------------------------------------------------------------------------- # +# Logout +# --------------------------------------------------------------------------- # +@auth_bp.route("/logout", methods=["POST"]) +@login_required +def logout(): + AuditLog.record("logout", user=current_user, ip=get_remote_ip()) + logout_user() + session.clear() + flash("Signed out.", "success") + return redirect(url_for("auth.login")) + + +# --------------------------------------------------------------------------- # +# Account: change password / set up MFA +# --------------------------------------------------------------------------- # +@auth_bp.route("/account", methods=["GET"]) +@login_required_full +def account(): + return render_template( + "account.html", + password_form=ChangePasswordForm(), + mfa_setup_form=MfaSetupForm(), + avatar_form=AvatarForm(), + ) + + +# --------------------------------------------------------------------------- # +# Avatar: upload / remove / serve +# --------------------------------------------------------------------------- # +@auth_bp.route("/account/avatar", methods=["POST"]) +@login_required_full +def avatar_upload(): + form = AvatarForm() + if form.validate_on_submit(): + try: + png_bytes = _normalise_avatar(form.avatar.data) + except ValueError as e: + flash(str(e), "error") + return redirect(url_for("auth.account")) + current_user.avatar_data = png_bytes + current_user.avatar_mime = "image/png" + db.session.commit() + AuditLog.record("avatar.update", user=current_user, ip=get_remote_ip()) + flash("Avatar updated.", "success") + else: + for errs in form.errors.values(): + for e in errs: + flash(e, "error") + return redirect(url_for("auth.account")) + + +@auth_bp.route("/account/avatar/remove", methods=["POST"]) +@login_required_full +def avatar_remove(): + current_user.avatar_data = None + current_user.avatar_mime = None + db.session.commit() + AuditLog.record("avatar.remove", user=current_user, ip=get_remote_ip()) + flash("Avatar removed.", "info") + return redirect(url_for("auth.account")) + + +@auth_bp.route("/avatar/", methods=["GET"]) +@login_required_full +def avatar(user_id: int): + user = db.session.get(User, user_id) + if user is None or not user.has_avatar: + abort(404) + resp = Response(user.avatar_data, mimetype=user.avatar_mime or "image/png") + resp.headers["Cache-Control"] = "private, max-age=60" + return resp + + +@auth_bp.route("/account/password", methods=["POST"]) +@login_required_full +def change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + if not current_user.check_password(form.current_password.data): + flash("Current password is incorrect.", "error") + else: + current_user.set_password(form.new_password.data) + db.session.commit() + AuditLog.record("password.change", user=current_user, ip=get_remote_ip()) + flash("Password updated.", "success") + return redirect(url_for("auth.account")) + return render_template( + "account.html", + password_form=form, + mfa_setup_form=MfaSetupForm(), + ) + + +@auth_bp.route("/account/mfa/setup", methods=["GET", "POST"]) +@login_required_full +def mfa_setup(): + """Generate a new TOTP secret + QR code; user confirms with a 6-digit code.""" + if current_user.mfa_enabled: + flash("MFA is already enabled.", "info") + return redirect(url_for("auth.account")) + + # Keep candidate secret in session until verified + if "mfa_setup_secret" not in session: + session["mfa_setup_secret"] = pyotp.random_base32() + + secret = session["mfa_setup_secret"] + issuer = current_app.config.get("MFA_ISSUER_NAME", "AlertHub") + uri = pyotp.TOTP(secret).provisioning_uri(name=current_user.email, issuer_name=issuer) + + # Generate QR code as base64 PNG + img = qrcode.make(uri) + buf = io.BytesIO() + img.save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii") + + form = MfaSetupForm() + if form.validate_on_submit(): + if pyotp.TOTP(secret).verify(form.code.data, valid_window=1): + current_user.mfa_secret = secret + current_user.mfa_enabled = True + db.session.commit() + session.pop("mfa_setup_secret", None) + AuditLog.record("mfa.enabled", user=current_user, ip=get_remote_ip()) + flash("MFA enabled successfully. You will be prompted for a code on next sign-in.", "success") + return redirect(url_for("auth.account")) + flash("Verification code did not match. Try again.", "error") + + return render_template("mfa_setup.html", form=form, qr_b64=qr_b64, secret=secret) + + +@auth_bp.route("/account/mfa/disable", methods=["POST"]) +@login_required_full +def mfa_disable(): + current_user.mfa_enabled = False + current_user.mfa_secret = None + db.session.commit() + AuditLog.record("mfa.disabled", user=current_user, ip=get_remote_ip()) + flash("MFA disabled.", "info") + return redirect(url_for("auth.account")) diff --git a/app/routes_backoffice.py b/app/routes_backoffice.py new file mode 100644 index 0000000..baea9a8 --- /dev/null +++ b/app/routes_backoffice.py @@ -0,0 +1,426 @@ +"""Backoffice routes — admins only. +Sections: + 3. Manage Users & Permissions + - Mail / SMTP configuration + - Global settings (default reminder days, scheduler, run-now) + - Audit log viewer +""" +import csv +import io +from datetime import date, datetime +from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, current_app, Response +from flask_login import current_user + +from .models import db, User, Alert, AppSetting, AuditLog, MailLog +from .forms import UserForm, MailConfigForm, TestMailForm, GlobalSettingsForm, LogEmailForm +from .auth_utils import admin_required, get_remote_ip +from .mailer import send_mail, get_mail_config +from .scheduler import trigger_now + +backoffice_bp = Blueprint("backoffice", __name__) + + +def _csv_response(filename: str, header: list, rows) -> Response: + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(header) + for r in rows: + writer.writerow(r) + data = buf.getvalue() + return Response( + data, + mimetype="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _audit_rows(): + for e in AuditLog.query.order_by(AuditLog.timestamp.desc()).all(): + yield [ + e.timestamp.strftime("%Y-%m-%d %H:%M:%S") if e.timestamp else "", + e.username or "", + e.action or "", + e.details or "", + e.ip_address or "", + ] + + +def _maillog_rows(): + for m in MailLog.query.order_by(MailLog.timestamp.desc()).all(): + yield [ + m.timestamp.strftime("%Y-%m-%d %H:%M:%S") if m.timestamp else "", + m.kind or "", + m.recipients or "", + m.subject or "", + m.status or "", + m.sent_copy or "", + m.triggered_by or "", + m.info or "", + ] + + +AUDIT_CSV_HEADER = ["timestamp_utc", "user", "action", "details", "ip"] +MAILLOG_CSV_HEADER = ["timestamp_utc", "kind", "recipients", "subject", "status", "sent_copy", "triggered_by", "info"] + + +def _build_csv_string(header, rows) -> str: + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(header) + for r in rows: + writer.writerow(r) + return buf.getvalue() + + +# --------------------------------------------------------------------------- # +# Backoffice home — admin dashboard +# --------------------------------------------------------------------------- # +@backoffice_bp.route("/") +@admin_required +def dashboard(): + user_count = User.query.count() + admin_count = User.query.filter_by(role="admin").count() + active_alerts = Alert.query.filter_by(is_active=True).count() + today = date.today() + expiring_soon = Alert.query.filter( + Alert.is_active.is_(True), + Alert.expiration_date >= today, + ).order_by(Alert.expiration_date.asc()).limit(5).all() + recent_audit = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(10).all() + return render_template( + "backoffice/dashboard.html", + user_count=user_count, + admin_count=admin_count, + active_alerts=active_alerts, + expiring_soon=expiring_soon, + recent_audit=recent_audit, + today=today, + ) + + +# --------------------------------------------------------------------------- # +# User management +# --------------------------------------------------------------------------- # +@backoffice_bp.route("/users") +@admin_required +def users_list(): + users = User.query.order_by(User.username.asc()).all() + return render_template("backoffice/users_list.html", users=users) + + +@backoffice_bp.route("/users/new", methods=["GET", "POST"]) +@admin_required +def user_new(): + form = UserForm() + if form.validate_on_submit(): + if not form.password.data: + flash("Password is required for a new user.", "error") + elif User.query.filter((User.username == form.username.data) | (User.email == form.email.data)).first(): + flash("Username or email already exists.", "error") + elif len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]: + flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error") + else: + user = User( + username=form.username.data.strip(), + email=form.email.data.strip().lower(), + role=form.role.data, + is_active=form.is_active.data, + ) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + AuditLog.record("user.create", user=current_user, details=f"new={user.username} role={user.role}", ip=get_remote_ip()) + flash(f"User '{user.username}' created.", "success") + return redirect(url_for("backoffice.users_list")) + return render_template("backoffice/user_form.html", form=form, user=None) + + +@backoffice_bp.route("/users//edit", methods=["GET", "POST"]) +@admin_required +def user_edit(user_id: int): + user = db.session.get(User, user_id) + if user is None: + abort(404) + form = UserForm(obj=user) + if form.validate_on_submit(): + # Username / email uniqueness check (excluding self) + clash = User.query.filter( + User.id != user.id, + (User.username == form.username.data) | (User.email == form.email.data.lower()) + ).first() + if clash: + flash("Another user already has that username or email.", "error") + else: + # Prevent self-demotion of last admin / self-deactivation lock-out + if user.id == current_user.id: + if form.role.data != "admin": + flash("You cannot demote yourself.", "error") + return render_template("backoffice/user_form.html", form=form, user=user) + if not form.is_active.data: + flash("You cannot deactivate your own account.", "error") + return render_template("backoffice/user_form.html", form=form, user=user) + + # Prevent removing the last active admin + if user.role == "admin" and form.role.data != "admin": + other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count() + if other_admins == 0: + flash("Cannot demote the last administrator.", "error") + return render_template("backoffice/user_form.html", form=form, user=user) + + user.username = form.username.data.strip() + user.email = form.email.data.strip().lower() + user.role = form.role.data + user.is_active = form.is_active.data + if form.password.data: + if len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]: + flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error") + return render_template("backoffice/user_form.html", form=form, user=user) + user.set_password(form.password.data) + db.session.commit() + AuditLog.record("user.update", user=current_user, details=f"id={user.id} username={user.username}", ip=get_remote_ip()) + flash("User updated.", "success") + return redirect(url_for("backoffice.users_list")) + return render_template("backoffice/user_form.html", form=form, user=user) + + +@backoffice_bp.route("/users//delete", methods=["POST"]) +@admin_required +def user_delete(user_id: int): + user = db.session.get(User, user_id) + if user is None: + abort(404) + if user.id == current_user.id: + flash("You cannot delete your own account.", "error") + return redirect(url_for("backoffice.users_list")) + if user.role == "admin": + other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count() + if other_admins == 0: + flash("Cannot delete the last administrator.", "error") + return redirect(url_for("backoffice.users_list")) + username = user.username + db.session.delete(user) + db.session.commit() + AuditLog.record("user.delete", user=current_user, details=f"deleted={username}", ip=get_remote_ip()) + flash(f"User '{username}' deleted.", "success") + return redirect(url_for("backoffice.users_list")) + + +@backoffice_bp.route("/users//reset-mfa", methods=["POST"]) +@admin_required +def user_reset_mfa(user_id: int): + user = db.session.get(User, user_id) + if user is None: + abort(404) + user.mfa_enabled = False + user.mfa_secret = None + db.session.commit() + AuditLog.record("user.mfa_reset", user=current_user, details=f"target={user.username}", ip=get_remote_ip()) + flash(f"MFA reset for '{user.username}'. They can re-enrol on next login.", "info") + return redirect(url_for("backoffice.users_list")) + + +@backoffice_bp.route("/users//unlock", methods=["POST"]) +@admin_required +def user_unlock(user_id: int): + user = db.session.get(User, user_id) + if user is None: + abort(404) + user.locked_until = None + user.failed_login_count = 0 + db.session.commit() + AuditLog.record("user.unlock", user=current_user, details=f"target={user.username}", ip=get_remote_ip()) + flash(f"Unlocked '{user.username}'.", "success") + return redirect(url_for("backoffice.users_list")) + + +# --------------------------------------------------------------------------- # +# Mail configuration +# --------------------------------------------------------------------------- # +@backoffice_bp.route("/mail-config", methods=["GET", "POST"]) +@admin_required +def mail_config(): + form = MailConfigForm() + test_form = TestMailForm() + + if request.method == "GET": + cfg = get_mail_config() + form.smtp_host.data = cfg["host"] + form.smtp_port.data = cfg["port"] + form.smtp_username.data = cfg["username"] + form.smtp_encryption.data = cfg["encryption"] + form.smtp_from_address.data = cfg["from_address"] + form.smtp_from_name.data = cfg["from_name"] + form.imap_enabled.data = cfg["imap_enabled"] + form.imap_host.data = cfg["imap_host"] + form.imap_port.data = cfg["imap_port"] + form.imap_username.data = cfg["imap_username"] + form.imap_sent_folder.data = cfg["imap_sent_folder"] + + if form.submit.data and form.validate_on_submit(): + AppSetting.set("smtp_host", form.smtp_host.data.strip()) + AppSetting.set("smtp_port", str(form.smtp_port.data)) + AppSetting.set("smtp_username", form.smtp_username.data.strip()) + if form.smtp_password.data: + AppSetting.set("smtp_password", form.smtp_password.data) + AppSetting.set("smtp_encryption", form.smtp_encryption.data) + AppSetting.set("smtp_from_address", form.smtp_from_address.data.strip().lower()) + AppSetting.set("smtp_from_name", (form.smtp_from_name.data or "").strip() or "AlertHub") + # ---- IMAP (Sent-folder copy) ---- + AppSetting.set("imap_enabled", "true" if form.imap_enabled.data else "false") + AppSetting.set("imap_host", (form.imap_host.data or "").strip()) + AppSetting.set("imap_port", str(form.imap_port.data or 993)) + AppSetting.set("imap_username", (form.imap_username.data or "").strip()) + if form.imap_password.data: + AppSetting.set("imap_password", form.imap_password.data) + AppSetting.set("imap_sent_folder", (form.imap_sent_folder.data or "").strip() or "Sent") + AuditLog.record("mailconfig.update", user=current_user, ip=get_remote_ip()) + flash("Mail configuration saved.", "success") + return redirect(url_for("backoffice.mail_config")) + + return render_template("backoffice/mail_config.html", form=form, test_form=test_form) + + +@backoffice_bp.route("/mail-config/test", methods=["POST"]) +@admin_required +def mail_test(): + test_form = TestMailForm() + if test_form.validate_on_submit(): + ok, info = send_mail( + test_form.test_recipient.data.strip(), + "[AlertHub] Test email", + "This is a test email from AlertHub. If you received this, SMTP is configured correctly.", + "

This is a test email from AlertHub. If you received this, SMTP is configured correctly.

", + kind="test", + triggered_by=current_user.username, + ) + AuditLog.record("mailconfig.test", user=current_user, details=f"to={test_form.test_recipient.data} ok={ok}", ip=get_remote_ip()) + flash(("Test email sent. " if ok else "Test failed: ") + info, "success" if ok else "error") + else: + flash("Provide a valid recipient email address.", "error") + return redirect(url_for("backoffice.mail_config")) + + +# --------------------------------------------------------------------------- # +# Global settings + scheduler trigger +# --------------------------------------------------------------------------- # +@backoffice_bp.route("/settings", methods=["GET", "POST"]) +@admin_required +def settings(): + form = GlobalSettingsForm() + if request.method == "GET": + form.default_reminder_days.data = AppSetting.get("default_reminder_days", "30,14,7,1") + if form.validate_on_submit(): + cleaned = ",".join( + str(x) for x in sorted({int(p.strip()) for p in form.default_reminder_days.data.split(",") if p.strip().isdigit()}, reverse=True) + ) + if not cleaned: + flash("Please enter at least one valid day value.", "error") + else: + AppSetting.set("default_reminder_days", cleaned) + AuditLog.record("settings.update", user=current_user, details=f"default_reminder_days={cleaned}", ip=get_remote_ip()) + flash("Settings saved.", "success") + return redirect(url_for("backoffice.settings")) + scheduler_interval = current_app.config.get("SCHEDULER_INTERVAL_MINUTES", 60) + return render_template("backoffice/settings.html", form=form, scheduler_interval=scheduler_interval) + + +@backoffice_bp.route("/settings/run-check", methods=["POST"]) +@admin_required +def run_check_now(): + """Manually run the reminder scan once.""" + try: + trigger_now(current_app._get_current_object()) + AuditLog.record("scheduler.manual_run", user=current_user, ip=get_remote_ip()) + flash("Reminder check executed.", "success") + except Exception as e: + current_app.logger.exception("Manual scheduler run failed") + flash(f"Reminder check failed: {e}", "error") + return redirect(url_for("backoffice.settings")) + + +# --------------------------------------------------------------------------- # +# Audit log viewer +# --------------------------------------------------------------------------- # +@backoffice_bp.route("/audit-log") +@admin_required +def audit_log(): + page = max(1, int(request.args.get("page", 1))) + per_page = 50 + pagination = AuditLog.query.order_by(AuditLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False) + return render_template("backoffice/audit_log.html", pagination=pagination, log_email_form=LogEmailForm()) + + +@backoffice_bp.route("/audit-log/export") +@admin_required +def audit_log_export(): + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + AuditLog.record("auditlog.export", user=current_user, ip=get_remote_ip()) + return _csv_response(f"alerthub-audit-log-{stamp}.csv", AUDIT_CSV_HEADER, _audit_rows()) + + +@backoffice_bp.route("/audit-log/email", methods=["POST"]) +@admin_required +def audit_log_email(): + form = LogEmailForm() + if not form.validate_on_submit(): + flash("Provide a valid destination email address.", "error") + return redirect(url_for("backoffice.audit_log")) + recipient = form.log_recipient.data.strip() + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + csv_str = _build_csv_string(AUDIT_CSV_HEADER, _audit_rows()) + ok, info = send_mail( + recipient, + f"[AlertHub] Audit log export ({stamp} UTC)", + "Attached is the AlertHub audit log export requested from the backoffice.", + "

Attached is the AlertHub audit log export requested from the backoffice.

", + attachments=[(f"alerthub-audit-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))], + kind="log-export", + triggered_by=current_user.username, + ) + AuditLog.record("auditlog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip()) + flash(("Audit log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error") + return redirect(url_for("backoffice.audit_log")) + + +# --------------------------------------------------------------------------- # +# Mail log viewer (every message the platform sends) +# --------------------------------------------------------------------------- # +@backoffice_bp.route("/mail-log") +@admin_required +def mail_log(): + page = max(1, int(request.args.get("page", 1))) + per_page = 50 + pagination = MailLog.query.order_by(MailLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False) + return render_template("backoffice/mail_log.html", pagination=pagination, log_email_form=LogEmailForm()) + + +@backoffice_bp.route("/mail-log/export") +@admin_required +def mail_log_export(): + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + AuditLog.record("maillog.export", user=current_user, ip=get_remote_ip()) + return _csv_response(f"alerthub-mail-log-{stamp}.csv", MAILLOG_CSV_HEADER, _maillog_rows()) + + +@backoffice_bp.route("/mail-log/email", methods=["POST"]) +@admin_required +def mail_log_email(): + form = LogEmailForm() + if not form.validate_on_submit(): + flash("Provide a valid destination email address.", "error") + return redirect(url_for("backoffice.mail_log")) + recipient = form.log_recipient.data.strip() + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + csv_str = _build_csv_string(MAILLOG_CSV_HEADER, _maillog_rows()) + ok, info = send_mail( + recipient, + f"[AlertHub] Mail log export ({stamp} UTC)", + "Attached is the AlertHub mail log export requested from the backoffice.", + "

Attached is the AlertHub mail log export requested from the backoffice.

", + attachments=[(f"alerthub-mail-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))], + kind="log-export", + triggered_by=current_user.username, + ) + AuditLog.record("maillog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip()) + flash(("Mail log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error") + return redirect(url_for("backoffice.mail_log")) diff --git a/app/routes_main.py b/app/routes_main.py new file mode 100644 index 0000000..4b5ebd1 --- /dev/null +++ b/app/routes_main.py @@ -0,0 +1,137 @@ +"""Frontoffice routes — visible to all authenticated users. + +Sections: + 1. View Data (everyone) + 2. Add/Remove/Edit (admins only) +""" +import json +from datetime import date +from flask import Blueprint, render_template, redirect, url_for, flash, request, abort +from flask_login import current_user + +from .models import db, Alert, AppSetting, AuditLog +from .forms import AlertForm +from .auth_utils import admin_required, login_required_full, get_remote_ip + +main_bp = Blueprint("main", __name__) + + +def _parse_days(text: str) -> list[int]: + return sorted({int(x.strip()) for x in text.split(",") if x.strip().isdigit() and int(x.strip()) >= 0}, reverse=True) + + +# --------------------------------------------------------------------------- # +# Section 1 — View Data +# --------------------------------------------------------------------------- # +@main_bp.route("/") +@login_required_full +def index(): + today = date.today() + alerts = Alert.query.filter_by(is_active=True).order_by(Alert.expiration_date.asc()).all() + payload = [] + for a in alerts: + days_left = (a.expiration_date - today).days + payload.append({ + "id": a.id, + "title": a.title, + "category": a.category, + "description": a.description, + "expiration_date": a.expiration_date.isoformat(), + "days_left": days_left, + "reminder_days": a.reminder_days, + }) + return render_template("index.html", alerts=payload, today_iso=today.isoformat()) + + +# --------------------------------------------------------------------------- # +# Section 2 — Add / Remove / Edit +# --------------------------------------------------------------------------- # +@main_bp.route("/alerts") +@login_required_full +def alerts_list(): + alerts = Alert.query.order_by(Alert.expiration_date.asc()).all() + return render_template("alerts_list.html", alerts=alerts) + + +@main_bp.route("/alerts/new", methods=["GET", "POST"]) +@admin_required +def alert_new(): + default_reminder_days = AppSetting.get("default_reminder_days", "30,14,7,1") + form = AlertForm(reminder_days=default_reminder_days) + if form.validate_on_submit(): + days = _parse_days(form.reminder_days.data) + if not days: + flash("Reminder days must contain at least one valid number.", "error") + else: + alert = Alert( + title=form.title.data.strip(), + category=(form.category.data or "").strip() or None, + description=(form.description.data or "").strip() or None, + expiration_date=form.expiration_date.data, + is_active=form.is_active.data, + created_by=current_user.id, + ) + alert.reminder_days = days + db.session.add(alert) + db.session.commit() + AuditLog.record("alert.create", user=current_user, details=f"id={alert.id} title={alert.title}", ip=get_remote_ip()) + flash("Alert created.", "success") + return redirect(url_for("main.alerts_list")) + return render_template("alert_form.html", form=form, alert=None) + + +@main_bp.route("/alerts//edit", methods=["GET", "POST"]) +@admin_required +def alert_edit(alert_id: int): + alert = db.session.get(Alert, alert_id) + if alert is None: + abort(404) + form = AlertForm(obj=alert) + if request.method == "GET": + form.reminder_days.data = ",".join(str(d) for d in alert.reminder_days) + if form.validate_on_submit(): + days = _parse_days(form.reminder_days.data) + if not days: + flash("Reminder days must contain at least one valid number.", "error") + else: + alert.title = form.title.data.strip() + alert.category = (form.category.data or "").strip() or None + alert.description = (form.description.data or "").strip() or None + alert.expiration_date = form.expiration_date.data + alert.is_active = form.is_active.data + # If reminder_days changed OR expiration_date changed, reset sent log + if days != alert.reminder_days: + alert.reminders_sent = [] + alert.reminder_days = days + db.session.commit() + AuditLog.record("alert.update", user=current_user, details=f"id={alert.id}", ip=get_remote_ip()) + flash("Alert updated.", "success") + return redirect(url_for("main.alerts_list")) + return render_template("alert_form.html", form=form, alert=alert) + + +@main_bp.route("/alerts//delete", methods=["POST"]) +@admin_required +def alert_delete(alert_id: int): + alert = db.session.get(Alert, alert_id) + if alert is None: + abort(404) + title = alert.title + db.session.delete(alert) + db.session.commit() + AuditLog.record("alert.delete", user=current_user, details=f"id={alert_id} title={title}", ip=get_remote_ip()) + flash(f"Alert '{title}' deleted.", "success") + return redirect(url_for("main.alerts_list")) + + +@main_bp.route("/alerts//reset-reminders", methods=["POST"]) +@admin_required +def alert_reset_reminders(alert_id: int): + alert = db.session.get(Alert, alert_id) + if alert is None: + abort(404) + alert.reminders_sent = [] + db.session.commit() + AuditLog.record("alert.reset_reminders", user=current_user, details=f"id={alert_id}", ip=get_remote_ip()) + flash("Reminder send-history cleared. Pending reminders will be re-sent on next scheduler run.", "info") + return redirect(url_for("main.alerts_list")) diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..4668a6b --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,79 @@ +"""Background scheduler that scans alerts and dispatches reminder emails.""" +import logging +from datetime import date, timezone +from apscheduler.schedulers.background import BackgroundScheduler +from .models import Alert, User, db +from .mailer import send_mail + +logger = logging.getLogger(__name__) +_scheduler: BackgroundScheduler | None = None + + +def _check_and_send(app): + """Iterate active alerts; for each reminder threshold reached, email all users.""" + with app.app_context(): + today = date.today() + active_alerts = Alert.query.filter_by(is_active=True).all() + recipients = [u.email for u in User.query.filter_by(is_active=True).all() if u.email] + if not recipients: + logger.info("Scheduler tick: no recipients.") + return + + for alert in active_alerts: + days_left = (alert.expiration_date - today).days + already_sent = set(alert.reminders_sent) + newly_sent = [] + for threshold in alert.reminder_days: + if days_left <= threshold and threshold not in already_sent: + subject = f"[AlertHub] Reminder: {alert.title} expires in {days_left} day(s)" + body_text = ( + f"Alert: {alert.title}\n" + f"Category: {alert.category or '—'}\n" + f"Expiration date: {alert.expiration_date.isoformat()}\n" + f"Days remaining: {days_left}\n\n" + f"{alert.description or ''}\n" + ) + body_html = f""" +
+

⏰ AlertHub Reminder

+

{alert.title}

+

Category: {alert.category or '—'}
+ Expiration date: {alert.expiration_date.isoformat()}
+ Days remaining: {days_left}

+

{alert.description or ''}

+
+ """ + ok, info = send_mail(recipients, subject, body_text, body_html, + kind="reminder", triggered_by="system") + logger.info("Alert %s threshold %d: send=%s (%s)", alert.id, threshold, ok, info) + if ok: + newly_sent.append(threshold) + if newly_sent: + alert.reminders_sent = list(already_sent.union(newly_sent)) + db.session.commit() + + +def init_scheduler(app): + """Start the scheduler if not already running. Called once at app startup.""" + global _scheduler + if _scheduler is not None and _scheduler.running: + return _scheduler + interval = max(1, int(app.config.get("SCHEDULER_INTERVAL_MINUTES", 60))) + _scheduler = BackgroundScheduler(timezone="UTC") + _scheduler.add_job( + _check_and_send, + "interval", + minutes=interval, + args=[app], + id="alert_reminder_job", + replace_existing=True, + next_run_time=None, # don't run immediately on boot + ) + _scheduler.start() + logger.info("Scheduler started; reminder check every %d minute(s).", interval) + return _scheduler + + +def trigger_now(app): + """Manual trigger (for the 'Run check now' button).""" + _check_and_send(app) diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..5b061a8 --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,869 @@ +/* ====================================================================== + AlertHub — IT operations console aesthetic + Typography: JetBrains Mono (display/mono), Space Grotesk avoided in favour + of system stack with mono accents to keep the terminal vibe. + ====================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap'); + +:root { + --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; + --font-sans: 'IBM Plex Sans', -apple-system, system-ui, sans-serif; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 14px; + + --tx-h: 1.15; + --tx-b: 1.55; +} + +/* ---------- Dark theme (default) ---------- */ +[data-theme="dark"] { + --bg-base: #0a0e14; + --bg-elev-1: #0f141b; + --bg-elev-2: #141a23; + --bg-elev-3: #1a212c; + --grid-line: rgba(0, 212, 170, 0.04); + --border: #1f2733; + --border-strong: #2a3442; + + --text-primary: #d8e2ec; + --text-secondary: #8b96a5; + --text-dim: #5a6371; + + --accent: #00d4aa; /* signal green */ + --accent-soft: rgba(0, 212, 170, 0.12); + --accent-hot: #00ffcc; + --magenta: #ff3e8b; /* alert magenta */ + --amber: #ffb454; + --danger: #ff5c5c; + --info: #4ad8ff; + + --shadow-1: 0 1px 0 rgba(255,255,255,0.03), 0 8px 24px rgba(0,0,0,0.5); + --glow-accent: 0 0 0 1px rgba(0,212,170,0.3), 0 0 20px rgba(0,212,170,0.15); +} + +/* ---------- Light theme ---------- */ +[data-theme="light"] { + --bg-base: #f4f6f9; + --bg-elev-1: #ffffff; + --bg-elev-2: #f9fbfd; + --bg-elev-3: #f0f3f7; + --grid-line: rgba(0, 130, 100, 0.05); + --border: #e2e8ef; + --border-strong: #c8d2dd; + + --text-primary: #0e1620; + --text-secondary: #4a5566; + --text-dim: #8693a3; + + --accent: #008f74; + --accent-soft: rgba(0, 143, 116, 0.08); + --accent-hot: #00b388; + --magenta: #d92671; + --amber: #d97a13; + --danger: #d93939; + --info: #1f9ec5; + + --shadow-1: 0 1px 2px rgba(15, 30, 45, 0.06), 0 8px 20px rgba(15, 30, 45, 0.05); + --glow-accent: 0 0 0 1px rgba(0, 143, 116, 0.25); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 15px; + line-height: var(--tx-b); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + min-height: 100vh; +} + +body { + position: relative; + overflow-x: hidden; +} + +/* Decorative grid background */ +.grid-bg { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 32px 32px; + mask-image: radial-gradient(ellipse at top, black 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse at top, black 30%, transparent 75%); +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hot); text-decoration: underline; text-underline-offset: 3px; } + +h1, h2, h3, h4 { + font-family: var(--font-sans); + font-weight: 700; + letter-spacing: -0.01em; + line-height: var(--tx-h); + margin: 0 0 .6em; +} +h1 { font-size: 1.85rem; } +h2 { font-size: 1.35rem; } +h3 { font-size: 1.1rem; } + +code, .mono { font-family: var(--font-mono); font-size: .92em; } + +/* ---------- Top bar ---------- */ +.topbar { + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); + background: color-mix(in srgb, var(--bg-elev-1) 82%, transparent); + border-bottom: 1px solid var(--border); +} +.topbar-inner { + max-width: 1320px; + margin: 0 auto; + padding: 12px 24px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 24px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + font-family: var(--font-mono); + font-weight: 700; + letter-spacing: -0.01em; +} +.brand:hover { text-decoration: none; } +.brand-mark { + color: var(--accent); + font-size: 1.1em; + letter-spacing: -2px; + filter: drop-shadow(0 0 6px var(--accent)); +} +.brand-name { font-size: 1.05rem; } +.brand-tag { + font-family: var(--font-mono); + font-size: .65rem; + font-weight: 500; + letter-spacing: 0.15em; + padding: 2px 7px; + border-radius: 4px; + background: var(--accent-soft); + color: var(--accent); + margin-left: 6px; +} +body.is-backoffice .brand-tag { + background: color-mix(in srgb, var(--magenta) 14%, transparent); + color: var(--magenta); +} + +.topbar-nav { + display: flex; + gap: 4px; + justify-self: center; +} +.navlink { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + font-family: var(--font-mono); + font-size: .85rem; + font-weight: 500; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: all .15s ease; +} +.navlink:hover { color: var(--text-primary); background: var(--bg-elev-2); text-decoration: none; } +.navlink.active { color: var(--accent); background: var(--accent-soft); } +.navlink.active .dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); } +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); +} +.dot-admin { background: var(--magenta); box-shadow: 0 0 8px var(--magenta); } +.navlink-cross { border: 1px solid var(--border); } +.navlink-cross:hover { border-color: var(--border-strong); color: var(--magenta); } +.navlink-cross.active { color: var(--magenta); background: color-mix(in srgb, var(--magenta) 12%, transparent); } +.navlink-cross.active .dot-admin { background: var(--magenta); } + +.topbar-right { display: inline-flex; gap: 10px; align-items: center; justify-self: end; } +.icon-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + width: 36px; height: 36px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all .15s ease; +} +.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-elev-2); } +.icon-sun { display: none; } +.icon-moon { display: block; } +[data-theme="light"] .icon-sun { display: block; } +[data-theme="light"] .icon-moon { display: none; } + +.user-chip { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px 12px 4px 4px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-primary); + background: var(--bg-elev-2); +} +.user-chip:hover { border-color: var(--border-strong); text-decoration: none; } +.user-avatar { + width: 28px; height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--info)); + color: #061218; + font-family: var(--font-mono); + font-size: .72rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} +.user-avatar-img { + object-fit: cover; + padding: 0; + border: 1px solid var(--border-strong); + background: var(--bg-elev-3); +} +.user-meta { display: flex; flex-direction: column; line-height: 1.1; } +.user-name { font-size: .85rem; } +.user-role { + font-family: var(--font-mono); + font-size: .6rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.user-role.role-admin { color: var(--magenta); } +.user-role.role-user { color: var(--text-dim); } + +.inline-form { display: inline; } + +/* ---------- Container ---------- */ +.container { + max-width: 1320px; + margin: 0 auto; + padding: 32px 24px 64px; + position: relative; + z-index: 1; +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: .85rem; + font-weight: 500; + border: 1px solid transparent; + cursor: pointer; + transition: all .15s ease; + white-space: nowrap; + text-decoration: none; +} +.btn:hover { text-decoration: none; } +.btn-primary { + background: var(--accent); + color: #02181a; + border-color: var(--accent); +} +.btn-primary:hover { background: var(--accent-hot); box-shadow: var(--glow-accent); color: #02181a; } +.btn-ghost { + background: transparent; + color: var(--text-primary); + border-color: var(--border); +} +.btn-ghost:hover { border-color: var(--border-strong); background: var(--bg-elev-2); } +.btn-danger { + background: transparent; + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 35%, transparent); +} +.btn-danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); } +.btn-sm { padding: 5px 10px; font-size: .78rem; } +.btn-block { display: flex; width: 100%; justify-content: center; } +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ---------- Cards / Panels ---------- */ +.panel { + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-1); +} +.panel-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.panel-header h2, .panel-header h3 { margin: 0; } +.panel-body { padding: 20px; } + +.section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin: 0 0 24px; + padding-bottom: 16px; + border-bottom: 1px dashed var(--border); +} +.section-head .lead { color: var(--text-secondary); margin: 6px 0 0; font-size: .95rem; } +.section-tag { + font-family: var(--font-mono); + font-size: .65rem; + letter-spacing: 0.15em; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 6px; + display: block; +} + +/* ---------- Forms ---------- */ +.form { display: grid; gap: 16px; } +.form label { + display: block; + font-family: var(--font-mono); + font-size: .78rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 6px; +} +.form input[type=text], +.form input[type=email], +.form input[type=password], +.form input[type=number], +.form input[type=date], +.form select, +.form textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: .95rem; + transition: all .15s ease; +} +.form textarea { font-family: var(--font-sans); resize: vertical; min-height: 90px; } +.form input:focus, .form select:focus, .form textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + background: var(--bg-elev-1); +} +.form .check-row { + display: inline-flex; + align-items: center; + gap: 10px; +} +.form .check-row label { + margin: 0; + text-transform: none; + letter-spacing: 0; + font-family: var(--font-sans); + font-size: .95rem; + font-weight: 500; + color: var(--text-primary); +} +.help { color: var(--text-dim); font-size: .82rem; margin-top: 4px; } +.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +@media (max-width: 720px) { .form-grid-2 { grid-template-columns: 1fr; } } + +.error { color: var(--danger); font-size: .82rem; margin-top: 4px; } + +/* ---------- Flash messages ---------- */ +.flash-stack { display: grid; gap: 10px; margin-bottom: 24px; } +.flash { + padding: 12px 16px; + border-radius: var(--radius-sm); + border: 1px solid; + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-elev-1); + font-size: .92rem; +} +.flash-icon { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} +.flash-success { border-color: color-mix(in srgb, var(--accent) 40%, transparent); } +.flash-success .flash-icon { background: var(--accent); box-shadow: 0 0 8px var(--accent); } +.flash-error { border-color: color-mix(in srgb, var(--danger) 40%, transparent); } +.flash-error .flash-icon { background: var(--danger); box-shadow: 0 0 8px var(--danger); } +.flash-info { border-color: color-mix(in srgb, var(--info) 40%, transparent); } +.flash-info .flash-icon { background: var(--info); } + +/* ---------- Auth pages ---------- */ +.auth-shell { + min-height: calc(100vh - 200px); + display: flex; + align-items: center; + justify-content: center; + padding: 40px 16px; +} +.auth-card { + width: 100%; + max-width: 420px; + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 32px; + box-shadow: var(--shadow-1); + position: relative; +} +.auth-card::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, var(--accent), transparent 40%, var(--magenta)); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: .5; + pointer-events: none; +} +.auth-prompt { + font-family: var(--font-mono); + font-size: .8rem; + color: var(--accent); + margin-bottom: 6px; + letter-spacing: 0.06em; +} +.auth-prompt::before { content: '> '; opacity: .7; } +.auth-card h1 { margin-bottom: 6px; font-size: 1.5rem; } +.auth-sub { color: var(--text-secondary); font-size: .9rem; margin-bottom: 24px; } + +/* ---------- ALERT cards (the heart of the View page) ---------- */ +.alerts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 18px; +} +.alert-card { + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 20px; + position: relative; + overflow: hidden; + transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease; +} +.alert-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-1); border-color: var(--border-strong); } +.alert-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: var(--accent); + opacity: .8; +} +.alert-card.is-warning::before { background: var(--amber); } +.alert-card.is-critical::before { background: var(--danger); } +.alert-card.is-expired::before { background: var(--text-dim); } + +.alert-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.alert-title { margin: 0; font-size: 1.1rem; font-weight: 700; line-height: 1.3; } +.alert-cat { + display: inline-block; + font-family: var(--font-mono); + font-size: .65rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 3px; + background: var(--bg-elev-3); + color: var(--text-secondary); +} +.alert-desc { + color: var(--text-secondary); + font-size: .9rem; + margin: 0 0 16px; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.alert-message { + font-family: var(--font-mono); + font-size: .85rem; + color: var(--text-secondary); + margin-bottom: 16px; + padding: 10px 12px; + background: var(--bg-elev-2); + border-left: 2px solid var(--accent); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.alert-card.is-warning .alert-message { border-left-color: var(--amber); } +.alert-card.is-critical .alert-message { border-left-color: var(--danger); } +.alert-card.is-expired .alert-message { border-left-color: var(--text-dim); } + +/* The countdown timer */ +.countdown { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} +.countdown-segment { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 4px 8px; + text-align: center; + position: relative; +} +.countdown-value { + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 700; + color: var(--accent); + line-height: 1; + display: block; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.alert-card.is-warning .countdown-value { color: var(--amber); } +.alert-card.is-critical .countdown-value { + color: var(--danger); + animation: pulse-num 1.4s ease-in-out infinite; +} +.alert-card.is-expired .countdown-value { color: var(--text-dim); } +.countdown-label { + font-family: var(--font-mono); + font-size: .55rem; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-dim); + display: block; + margin-top: 4px; +} +@keyframes pulse-num { + 0%, 100% { opacity: 1; } + 50% { opacity: .55; } +} + +.alert-meta { + margin-top: 14px; + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--font-mono); + font-size: .72rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.alert-status-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + margin-right: 6px; + box-shadow: 0 0 6px var(--accent); +} +.alert-card.is-warning .alert-status-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); } +.alert-card.is-critical .alert-status-dot { + background: var(--danger); + box-shadow: 0 0 8px var(--danger); + animation: blink 1s ease-in-out infinite; +} +.alert-card.is-expired .alert-status-dot { background: var(--text-dim); box-shadow: none; } +@keyframes blink { 50% { opacity: .25; } } + +/* Empty state */ +.empty { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); + border: 1px dashed var(--border); + border-radius: var(--radius-md); +} +.empty .empty-icon { font-size: 2rem; opacity: .5; margin-bottom: 12px; } + +/* ---------- Tables ---------- */ +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} +.table th, .table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.table thead th { + background: var(--bg-elev-2); + font-family: var(--font-mono); + font-size: .72rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-secondary); +} +.table tbody tr:hover { background: var(--bg-elev-2); } +.table tbody tr:last-child td { border-bottom: none; } +.table .actions { text-align: right; white-space: nowrap; } + +.role-badge { + display: inline-block; + font-family: var(--font-mono); + font-size: .68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 3px; +} +.role-badge.role-admin { background: color-mix(in srgb, var(--magenta) 14%, transparent); color: var(--magenta); } +.role-badge.role-user { background: var(--bg-elev-3); color: var(--text-secondary); } + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: .7rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.status-badge .led { width: 6px; height: 6px; border-radius: 50%; } +.status-active .led { background: var(--accent); box-shadow: 0 0 6px var(--accent); } +.status-inactive .led { background: var(--text-dim); } + +/* ---------- Stat cards ---------- */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 28px; +} +.stat { + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 18px; + position: relative; +} +.stat-label { + font-family: var(--font-mono); + font-size: .68rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-dim); +} +.stat-value { + font-family: var(--font-mono); + font-size: 2rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; + margin-top: 6px; +} +.stat-accent .stat-value { color: var(--accent); } +.stat-warn .stat-value { color: var(--amber); } + +/* ---------- QR / MFA pages ---------- */ +.mfa-qr { + display: block; + margin: 0 auto; + width: 200px; + height: 200px; + background: white; + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} +.mfa-secret { + font-family: var(--font-mono); + font-size: .9rem; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + user-select: all; + word-break: break-all; + text-align: center; +} + +/* ---------- Footer ---------- */ +.footer { + max-width: 1320px; + margin: 0 auto; + padding: 18px 24px 36px; + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono); + font-size: .75rem; + color: var(--text-dim); + border-top: 1px solid var(--border); +} +/* ---------- Avatar panel (account page) ---------- */ +.avatar-row { + display: flex; + gap: 24px; + align-items: flex-start; + flex-wrap: wrap; +} +.avatar-preview { + width: 96px; height: 96px; + border-radius: 50%; + overflow: hidden; + flex: 0 0 auto; + border: 1px solid var(--border-strong); + background: var(--bg-elev-3); + display: inline-flex; + align-items: center; + justify-content: center; +} +.avatar-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } +.avatar-initials { + font-family: var(--font-mono); + font-size: 1.8rem; + font-weight: 700; + color: var(--accent); +} +.avatar-actions { flex: 1 1 260px; min-width: 260px; } + +/* ---------- Log export / email actions ---------- */ +.log-actions { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.log-email-form { + display: inline-flex; + gap: 8px; + align-items: center; +} +.log-email-form input[type="text"], +.log-email-form input[type="email"] { margin: 0; min-width: 220px; } +@media (max-width: 720px) { + .log-actions { width: 100%; } + .log-email-form { width: 100%; } + .log-email-form input { flex: 1; min-width: 0; } +} + +.footer-sep { opacity: .5; } +.footer-copy { + margin-left: auto; + color: var(--text-secondary); + letter-spacing: .02em; +} +.footer .status-pill { margin-left: 18px; } +@media (max-width: 720px) { + .footer { flex-wrap: wrap; } + .footer-copy { margin-left: 0; flex-basis: 100%; order: 3; } + .footer .status-pill { margin-left: auto; } +} +.kbd { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 6px; + font-size: .72rem; + color: var(--text-secondary); +} +.status-pill { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + text-transform: uppercase; +} +.status-pill .led { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px var(--accent); + animation: led-pulse 2.5s ease-in-out infinite; +} +@keyframes led-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .4; } +} + +/* ---------- Pagination ---------- */ +.pagination { + display: flex; + justify-content: center; + gap: 6px; + margin-top: 20px; + flex-wrap: wrap; +} +.pagination a, .pagination span { + font-family: var(--font-mono); + font-size: .82rem; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); +} +.pagination a:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; } +.pagination .current { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); } + +/* ---------- Misc ---------- */ +.row-spread { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.row-gap { display: flex; gap: 12px; flex-wrap: wrap; } +.muted { color: var(--text-secondary); } +.dim { color: var(--text-dim); } +hr { border: 0; border-top: 1px dashed var(--border); margin: 24px 0; } + +@media (max-width: 800px) { + .topbar-inner { grid-template-columns: 1fr; gap: 12px; } + .topbar-nav { justify-self: flex-start; flex-wrap: wrap; } + .topbar-right { justify-self: flex-start; } +} diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..6dd6096 --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..1d6a756 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,73 @@ +// ====== Theme toggle ======================================================= +(function () { + const btn = document.getElementById('theme-toggle'); + if (!btn) return; + btn.addEventListener('click', () => { + const root = document.documentElement; + const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; + root.setAttribute('data-theme', next); + try { localStorage.setItem('alerthub-theme', next); } catch (_) {} + }); +})(); + +// ====== Countdown timer ==================================================== +// Each .countdown element has data-expires="YYYY-MM-DD" and renders 4 cells +// for days / hours / minutes / seconds. Updates every second. +(function () { + const els = document.querySelectorAll('.countdown[data-expires]'); + if (!els.length) return; + + function pad(n) { return n < 10 ? '0' + n : '' + n; } + + function render() { + const now = new Date(); + els.forEach((el) => { + const target = new Date(el.dataset.expires + 'T23:59:59'); + let diff = (target.getTime() - now.getTime()) / 1000; + const card = el.closest('.alert-card'); + const negative = diff < 0; + diff = Math.abs(diff); + + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + const minutes = Math.floor((diff % 3600) / 60); + const seconds = Math.floor(diff % 60); + + const segs = el.querySelectorAll('.countdown-value'); + if (segs.length === 4) { + segs[0].textContent = days; + segs[1].textContent = pad(hours); + segs[2].textContent = pad(minutes); + segs[3].textContent = pad(seconds); + } + + if (card) { + card.classList.remove('is-warning', 'is-critical', 'is-expired'); + if (negative) { + card.classList.add('is-expired'); + } else if (days < 7) { + card.classList.add('is-critical'); + } else if (days < 30) { + card.classList.add('is-warning'); + } + } + + const dotLabel = el.parentElement && el.parentElement.querySelector('.alert-state-text'); + if (dotLabel) { + if (negative) dotLabel.textContent = 'EXPIRED'; + else if (days < 7) dotLabel.textContent = 'CRITICAL'; + else if (days < 30) dotLabel.textContent = 'WARNING'; + else dotLabel.textContent = 'NOMINAL'; + } + }); + } + render(); + setInterval(render, 1000); +})(); + +// ====== Confirm-on-click for destructive actions =========================== +document.querySelectorAll('form[data-confirm]').forEach((form) => { + form.addEventListener('submit', (e) => { + if (!window.confirm(form.dataset.confirm)) e.preventDefault(); + }); +}); diff --git a/app/templates/account.html b/app/templates/account.html new file mode 100644 index 0000000..faf373f --- /dev/null +++ b/app/templates/account.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Account{% endblock %} +{% block content %} +
+
+ +

{{ current_user.username }}

+

{{ current_user.email }} · role {{ current_user.role }}

+
+
+ +
+

Profile avatar

+
+
+
+ {% if current_user.has_avatar %} + Current avatar + {% else %} + {{ current_user.initials }} + {% endif %} +
+
+
+ {{ avatar_form.csrf_token }} +
{{ avatar_form.avatar.label }}{{ avatar_form.avatar(accept="image/*") }}
+
PNG, JPG, GIF or WebP. Squared and resized to {{ 256 }}×{{ 256 }} automatically. Max 2 MB.
+
+ {{ avatar_form.submit(class_="btn btn-primary") }} + {% if current_user.has_avatar %} + + {% endif %} +
+
+ {% if current_user.has_avatar %} +
+ +
+ {% endif %} +
+
+
+
+ +
+
+

Change password

+
+
+ {{ password_form.csrf_token }} +
{{ password_form.current_password.label }}{{ password_form.current_password(autocomplete="current-password") }}
+
{{ password_form.new_password.label }}{{ password_form.new_password(autocomplete="new-password") }}
+
{{ password_form.confirm.label }}{{ password_form.confirm(autocomplete="new-password") }}
+ {{ password_form.submit(class_="btn btn-primary") }} +
+
+
+ +
+
+

Multi-factor authentication

+ + {{ 'enabled' if current_user.mfa_enabled else 'disabled' }} + +
+
+ {% if current_user.mfa_enabled %} +

MFA is currently active on your account. You'll be prompted for a 6-digit code on every sign-in.

+
+ + +
+ {% else %} +

MFA is currently not configured. Strongly recommended for admin accounts.

+ Set up MFA now + {% endif %} +
+
+
+ +{% endblock %} diff --git a/app/templates/alert_form.html b/app/templates/alert_form.html new file mode 100644 index 0000000..a2b4fb2 --- /dev/null +++ b/app/templates/alert_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if alert else 'New' }} alert{% endblock %} +{% block content %} +
+
+ +

{{ 'Edit alert' if alert else 'New alert' }}

+ {% if alert %}

Last updated {{ alert.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} UTC.

{% endif %} +
+ ← Back +
+ +
+
+
+ {{ form.csrf_token }} +
+ {{ form.title.label }}{{ form.title(placeholder="e.g. Microsoft 365 Renewal") }} + {% for err in form.title.errors %}
{{ err }}
{% endfor %} +
+
+
+ {{ form.category.label }}{{ form.category(placeholder="License, SSL, Domain, Contract …") }} + {% for err in form.category.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.expiration_date.label }}{{ form.expiration_date() }} + {% for err in form.expiration_date.errors %}
{{ err }}
{% endfor %} +
+
+
+ {{ form.description.label }}{{ form.description(rows=4, placeholder="Optional detail shown on the alert card.") }} + {% for err in form.description.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.reminder_days.label }}{{ form.reminder_days() }} +
Comma-separated days BEFORE expiration when an email reminder fires. Example: 30,14,7,1
+ {% for err in form.reminder_days.errors %}
{{ err }}
{% endfor %} +
+
{{ form.is_active() }} {{ form.is_active.label }}
+
+ {{ form.submit(class_="btn btn-primary") }} + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/alerts_list.html b/app/templates/alerts_list.html new file mode 100644 index 0000000..e0e2ed2 --- /dev/null +++ b/app/templates/alerts_list.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Manage Alerts{% endblock %} +{% block content %} +
+
+ +

Manage alerts

+

Create, modify, or retire alerts. Only administrators can make changes.

+
+ {% if current_user.is_admin %} + + New alert + {% endif %} +
+ +{% if alerts %} + + + + + + + + + + + + + {% for a in alerts %} + + + + + + + + + {% endfor %} + +
TitleCategoryExpiresRemindersStatusActions
{{ a.title }}{% if a.description %}
{{ a.description[:80] }}{% if a.description|length > 80 %}…{% endif %}{% endif %}
{% if a.category %}{{ a.category }}{% else %}{% endif %}{{ a.expiration_date.isoformat() }}{{ a.reminder_days|join(', ') }}d + + {{ 'active' if a.is_active else 'inactive' }} + + + {% if current_user.is_admin %} + Edit +
+ + +
+
+ + +
+ {% else %} + view only + {% endif %} +
+{% else %} +
+

No alerts yet.

+ {% if current_user.is_admin %}Create one{% endif %} +
+{% endif %} +{% endblock %} diff --git a/app/templates/backoffice/audit_log.html b/app/templates/backoffice/audit_log.html new file mode 100644 index 0000000..c78f147 --- /dev/null +++ b/app/templates/backoffice/audit_log.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Audit log{% endblock %} +{% block content %} +
+
+ +

Audit log

+

Sign-ins, configuration changes, and alert mutations. Most-recent first.

+
+
+ ⬇ Export CSV +
+ {{ log_email_form.csrf_token }} + {{ log_email_form.log_recipient(placeholder="destination@example.com") }} + {{ log_email_form.submit(class_="btn btn-primary") }} +
+
+
+ + + + + + + {% for e in pagination.items %} + + + + + + + + {% else %} + + {% endfor %} + +
Timestamp (UTC)UserActionDetailsIP
{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ e.username or '—' }}{{ e.action }}{{ e.details or '' }}{{ e.ip_address or '' }}
No audit entries yet.
+ +{% if pagination.pages > 1 %} + +{% endif %} +{% endblock %} diff --git a/app/templates/backoffice/dashboard.html b/app/templates/backoffice/dashboard.html new file mode 100644 index 0000000..41b6226 --- /dev/null +++ b/app/templates/backoffice/dashboard.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +
+
+ +

Operations dashboard

+

System status as of {{ today.isoformat() }}.

+
+
+ +
+
+
Active alerts
+
{{ active_alerts }}
+
+
+
Users
+
{{ user_count }}
+
+
+
Administrators
+
{{ admin_count }}
+
+
+ +
+
+
+

Expiring soonest

+ settings → +
+
+ {% if expiring_soon %} + + + + + + {% for a in expiring_soon %} + {% set days = (a.expiration_date - today).days %} + + + + + + {% endfor %} + +
TitleExpiresDays
{{ a.title }}{% if a.category %} {{ a.category }}{% endif %}{{ a.expiration_date.isoformat() }}{{ days }}d
+ {% else %} +

No upcoming alerts.

+ {% endif %} +
+
+ +
+

Recent activity

+ full log → +
+
+ {% if recent_audit %} +
    + {% for entry in recent_audit %} +
  • + {{ entry.action }} {% if entry.username %}· {{ entry.username }}{% endif %}{% if entry.details %}
    {{ entry.details }}{% endif %}
    + {{ entry.timestamp.strftime('%m-%d %H:%M') }} +
  • + {% endfor %} +
+ {% else %} +

No activity yet.

+ {% endif %} +
+
+
+ +{% endblock %} diff --git a/app/templates/backoffice/mail_config.html b/app/templates/backoffice/mail_config.html new file mode 100644 index 0000000..14ef9c0 --- /dev/null +++ b/app/templates/backoffice/mail_config.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}Mail configuration{% endblock %} +{% block content %} +
+
+ +

SMTP configuration

+

External mail server used to dispatch alert reminders. Edits take effect immediately.

+
+
+ +
+
+

Server settings

+
+
+ {{ form.csrf_token }} +
+
+ {{ form.smtp_host.label }}{{ form.smtp_host(placeholder="smtp.example.com") }} + {% for err in form.smtp_host.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.smtp_port.label }}{{ form.smtp_port() }} + {% for err in form.smtp_port.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.smtp_encryption.label }}{{ form.smtp_encryption() }} +
587 → STARTTLS · 465 → SSL/TLS · 25 → none
+
+
+ {{ form.smtp_username.label }}{{ form.smtp_username(autocomplete="off") }} +
+
+ {{ form.smtp_password.label }}{{ form.smtp_password(autocomplete="new-password") }} +
Leave blank to keep the current password.
+
+
+ {{ form.smtp_from_address.label }}{{ form.smtp_from_address() }} +
+
+ {{ form.smtp_from_name.label }}{{ form.smtp_from_name() }} +
+
+ +
+

Sent-folder copy (IMAP)

+

When enabled, a copy of every message the platform sends is saved to the mailbox's Sent folder over IMAP, so it appears in your normal mail client.

+
{{ form.imap_enabled() }} {{ form.imap_enabled.label }}
+
+
+ {{ form.imap_host.label }}{{ form.imap_host(placeholder="imap.purelymail.com") }} + {% for err in form.imap_host.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.imap_port.label }}{{ form.imap_port(placeholder="993") }} + {% for err in form.imap_port.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.imap_sent_folder.label }}{{ form.imap_sent_folder(placeholder="Sent") }} +
Usually Sent.
+
+
+ {{ form.imap_username.label }}{{ form.imap_username(autocomplete="off") }} +
+
+ {{ form.imap_password.label }}{{ form.imap_password(autocomplete="new-password") }} +
Blank keeps current; if never set, the SMTP password is used.
+
+
+ + {{ form.submit(class_="btn btn-primary") }} +
+
+
+ +
+

Send test email

+
+

Confirms credentials, port, and encryption are correct.

+
+ {{ test_form.csrf_token }} +
{{ test_form.test_recipient.label }}{{ test_form.test_recipient(placeholder="you@example.com") }}
+ {{ test_form.submit(class_="btn btn-ghost btn-block") }} +
+
+
+
+ +{% endblock %} diff --git a/app/templates/backoffice/mail_log.html b/app/templates/backoffice/mail_log.html new file mode 100644 index 0000000..3370c4b --- /dev/null +++ b/app/templates/backoffice/mail_log.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Mail log{% endblock %} +{% block content %} +
+
+ +

Mail log

+

Every message the platform has dispatched, whether a copy reached the Sent folder, and its delivery result. Most-recent first.

+
+
+ ⬇ Export CSV +
+ {{ log_email_form.csrf_token }} + {{ log_email_form.log_recipient(placeholder="destination@example.com") }} + {{ log_email_form.submit(class_="btn btn-primary") }} +
+
+
+ + + + + + + + + + {% for m in pagination.items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
Timestamp (UTC)KindRecipientsSubjectStatusSent copyByInfo
{{ m.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ m.kind }}{{ m.recipients or '—' }}{{ m.subject or '—' }} + {% if m.status == 'sent' %} + sent + {% else %} + failed + {% endif %} + + {% if m.sent_copy == 'saved' %} + saved + {% elif m.sent_copy == 'failed' %} + failed + {% elif m.sent_copy == 'disabled' %} + disabled + {% else %} + + {% endif %} + {{ m.triggered_by or 'system' }}{{ m.info or '' }}
No mail has been sent yet.
+ +{% if pagination.pages > 1 %} + +{% endif %} +{% endblock %} diff --git a/app/templates/backoffice/settings.html b/app/templates/backoffice/settings.html new file mode 100644 index 0000000..f637740 --- /dev/null +++ b/app/templates/backoffice/settings.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Settings{% endblock %} +{% block content %} +
+
+ +

Global settings

+

Default reminder cadence and scheduler controls.

+
+
+ +
+
+

Default reminder schedule

+
+
+ {{ form.csrf_token }} +
+ {{ form.default_reminder_days.label }}{{ form.default_reminder_days() }} +
When creating a new alert, this is the default. Existing alerts are unaffected.
+ {% for err in form.default_reminder_days.errors %}
{{ err }}
{% endfor %} +
+ {{ form.submit(class_="btn btn-primary") }} +
+
+
+ +
+

Scheduler

+
+

Reminder dispatch runs every {{ scheduler_interval }} minute(s).

+

Configured via the SCHEDULER_INTERVAL_MINUTES environment variable. Restart the container to change it.

+ +
+ +

You can trigger a check immediately to verify reminders dispatch correctly.

+
+ + +
+
+
+
+ +{% endblock %} diff --git a/app/templates/backoffice/user_form.html b/app/templates/backoffice/user_form.html new file mode 100644 index 0000000..ee90e1a --- /dev/null +++ b/app/templates/backoffice/user_form.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if user else 'New' }} user{% endblock %} +{% block content %} +
+
+ +

{{ 'Edit user' if user else 'New user' }}

+ {% if user %}

Created {{ user.created_at.strftime('%Y-%m-%d') }}

{% endif %} +
+ ← Back +
+ +
+
+
+ {{ form.csrf_token }} +
+
{{ form.username.label }}{{ form.username() }} + {% for err in form.username.errors %}
{{ err }}
{% endfor %} +
+
{{ form.email.label }}{{ form.email() }} + {% for err in form.email.errors %}
{{ err }}
{% endfor %} +
+
+
+
{{ form.role.label }}{{ form.role() }}
+
{{ form.is_active() }} {{ form.is_active.label }}
+
+
+ {{ form.password.label }}{{ form.password(autocomplete="new-password") }} +
{% if user %}Leave blank to keep the existing password.{% else %}Min {{ config.PASSWORD_MIN_LENGTH }} characters.{% endif %}
+ {% for err in form.password.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.submit(class_="btn btn-primary") }} + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/backoffice/users_list.html b/app/templates/backoffice/users_list.html new file mode 100644 index 0000000..48a7e76 --- /dev/null +++ b/app/templates/backoffice/users_list.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Users{% endblock %} +{% block content %} +
+
+ +

Users

+

Manage accounts, roles, MFA, and lockouts.

+
+ + New user +
+ + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
UsernameEmailRoleMFAStatusLast loginActions
{{ u.username }}{% if u.id == current_user.id %} (you){% endif %}{{ u.email }}{{ u.role }} + + {{ 'on' if u.mfa_enabled else 'off' }} + + + {% if u.is_locked %} + locked + {% else %} + {{ 'active' if u.is_active else 'disabled' }} + {% endif %} + + {% if u.last_login_at %}{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') }}{% else %}never{% endif %} + + Edit + {% if u.mfa_enabled %} +
+ + +
+ {% endif %} + {% if u.is_locked %} +
+ + +
+ {% endif %} + {% if u.id != current_user.id %} +
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..3b74e3b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,136 @@ + + + + + + + {% block title %}{{ brand_name }}{% endblock %} — {{ brand_name }}{% if is_backoffice %} · backoffice{% endif %} + + + + + + + + +
+
+ + ▮▮ + {{ brand_name }} + {{ 'BACKOFFICE' if is_backoffice else 'FRONTOFFICE' }} + + + {% if current_user.is_authenticated and not session.get('mfa_pending') %} + + + + {% else %} +
+ +
+ {% endif %} +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
+ + {{ msg }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + {% if request.endpoint != 'auth.login' %} +
+ {{ brand_name }} + · + {{ 'admin console' if is_backoffice else 'alert console' }} + {{ footer_text }} + operational +
+ {% endif %} + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..d964f8d --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}{{ code }} · Error{% endblock %} +{% block content %} +
+
+
error
+

{{ code }}

+

{{ message }}

+ ← Return home +
+
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..555e91f --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}View · Alerts{% endblock %} +{% block content %} +
+
+ +

Active alerts

+

Live countdown to expiration. Reminders are dispatched automatically by email per the configured schedule.

+
+
+ today: {{ today_iso }} + {% if current_user.is_admin %} + + New alert + {% endif %} +
+
+ +{% if alerts %} +
+ {% for a in alerts %} + {% set state = 'is-expired' if a.days_left < 0 else ('is-critical' if a.days_left < 7 else ('is-warning' if a.days_left < 30 else '')) %} + {% set state_text = 'EXPIRED' if a.days_left < 0 else ('CRITICAL' if a.days_left < 7 else ('WARNING' if a.days_left < 30 else 'NOMINAL')) %} +
+
+

{{ a.title }}

+ {% if a.category %}{{ a.category }}{% endif %} +
+ {% if a.description %}

{{ a.description }}

{% endif %} + +
+ {{ a.title }} · expires {{ a.expiration_date }} +
+ +
+
days
+
hours
+
min
+
sec
+
+ +
+ {{ state_text }} + reminders: {{ a.reminder_days|join(', ') }}d +
+
+ {% endfor %} +
+{% else %} +
+
▮▮
+

No active alerts.

+ {% if current_user.is_admin %} +

Add your first one — license renewals, certificates, contracts, …

+ Create the first alert + {% else %} +

Ask an administrator to create alerts for your team.

+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..776e0ee --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Sign in{% endblock %} +{% block content %} +
+
+
authenticate
+

Sign in to {{ brand_name }}

+

{% if is_backoffice %}Administrator console.{% else %}Alert operations console.{% endif %}

+
+ {{ form.csrf_token }} +
+ {{ form.username.label }} + {{ form.username(autocomplete="username", autofocus=True) }} +
+
+ {{ form.password.label }} + {{ form.password(autocomplete="current-password") }} +
+ {{ form.submit(class_="btn btn-primary btn-block") }} +
+
+
+{% endblock %} diff --git a/app/templates/mfa.html b/app/templates/mfa.html new file mode 100644 index 0000000..c2275e2 --- /dev/null +++ b/app/templates/mfa.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Verify identity{% endblock %} +{% block content %} +
+
+
verify
+

Two-factor authentication

+

Enter the 6-digit code from your authenticator app.

+
+ {{ form.csrf_token }} +
+ {{ form.code.label }} + {{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", autofocus=True, maxlength=6) }} +
+ {{ form.submit(class_="btn btn-primary btn-block") }} +
+
+
+{% endblock %} diff --git a/app/templates/mfa_setup.html b/app/templates/mfa_setup.html new file mode 100644 index 0000000..7c07a59 --- /dev/null +++ b/app/templates/mfa_setup.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Set up MFA{% endblock %} +{% block content %} +
+
+ +

Enable multi-factor authentication

+

Scan the QR code with your authenticator (Google Authenticator, Authy, 1Password, …) and enter the 6-digit code to confirm.

+
+
+ +
+
+ MFA QR code +

Manual key (if you can't scan):

+
{{ secret }}
+ +
+ +
+ {{ form.csrf_token }} +
+ {{ form.code.label }} + {{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", maxlength=6) }} +
Enter the current 6-digit code shown in your authenticator.
+
+
+ {{ form.submit(class_="btn btn-primary") }} + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/setup.html b/app/templates/setup.html new file mode 100644 index 0000000..dffc1c5 --- /dev/null +++ b/app/templates/setup.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Initial setup{% endblock %} +{% block content %} +
+
+
first-run setup
+

Create administrator

+

No accounts exist yet. Create the first administrator to secure {{ brand_name }}.

+
+ {{ form.csrf_token }} +
+ {{ form.username.label }} + {{ form.username(autocomplete="username", autofocus=True) }} + {% for err in form.username.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.email.label }} + {{ form.email(autocomplete="email") }} + {% for err in form.email.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.password.label }} + {{ form.password(autocomplete="new-password") }} + {% for err in form.password.errors %}
{{ err }}
{% endfor %} +
+
+ {{ form.confirm.label }} + {{ form.confirm(autocomplete="new-password") }} + {% for err in form.confirm.errors %}
{{ err }}
{% endfor %} +
+ {{ form.submit(class_="btn btn-primary btn-block") }} +
+
+
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e7cbae8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# docker-compose.yml +# All runtime configuration is read from the .env file in the same folder. +# This file intentionally contains NO secrets and NO environment values. + +services: + alerthub: + build: + context: . + dockerfile: Dockerfile + image: alerthub:latest + container_name: alerthub + restart: unless-stopped + env_file: + - .env + ports: + - "${FRONTOFFICE_PORT}:${FRONTOFFICE_PORT}" + volumes: + - alerthub_data:/data + # Hardening + read_only: false # SQLite needs to write to /data; the rest of FS is owned by non-root user + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${FRONTOFFICE_PORT}/login"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + +volumes: + alerthub_data: + name: alerthub_data diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..16727fe --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Starts the unified AlertHub app (frontoffice + /backoffice) on a single port. +# POSIX /bin/sh (dash) compatible. Uses exec so Gunicorn becomes the container's +# main process and receives signals directly for clean, graceful shutdown. + +set -eu + +PORT="${APP_PORT:-${FRONTOFFICE_PORT:-8080}}" +# A single worker guarantees exactly one reminder scheduler. Concurrency is +# handled with threads, which is plenty for this internal tool. You can raise +# GUNICORN_THREADS if needed; leave GUNICORN_WORKERS at 1 to avoid running the +# scheduler more than once. +WORKERS="${GUNICORN_WORKERS:-1}" +THREADS="${GUNICORN_THREADS:-4}" + +echo ">> AlertHub starting (frontoffice + /backoffice on one port)" +echo " listening -> 0.0.0.0:${PORT}" + +exec gunicorn --bind "0.0.0.0:${PORT}" \ + --workers "${WORKERS}" \ + --threads "${THREADS}" \ + --timeout 60 \ + --graceful-timeout 30 \ + --access-logfile - \ + --error-logfile - \ + "wsgi:application" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7360340 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +Flask==3.1.3 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +WTForms==3.2.1 +SQLAlchemy==2.0.36 +Flask-SQLAlchemy==3.1.1 +bcrypt==4.2.1 +pyotp==2.9.0 +qrcode[pil]==8.0 +Pillow==11.1.0 +APScheduler==3.11.0 +python-dotenv==1.0.1 +gunicorn==23.0.0 +email-validator==2.2.0 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..ee21a41 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,13 @@ +"""WSGI entry point for AlertHub. + +Serves the whole application — the frontoffice at the root and the backoffice +under /backoffice — on a single port. +""" +from app import create_app + +application = create_app() + +if __name__ == "__main__": + import os + port = int(os.environ.get("APP_PORT", os.environ.get("FRONTOFFICE_PORT", "8080"))) + application.run(host="0.0.0.0", port=port, debug=False) diff --git a/wsgi_backoffice.py b/wsgi_backoffice.py new file mode 100644 index 0000000..e31005a --- /dev/null +++ b/wsgi_backoffice.py @@ -0,0 +1,9 @@ +"""WSGI entry point for the backoffice (port 9001).""" +from app import create_app + +application = create_app("backoffice") + +if __name__ == "__main__": + import os + port = int(os.environ.get("BACKOFFICE_PORT", "9001")) + application.run(host="0.0.0.0", port=port, debug=False) diff --git a/wsgi_frontoffice.py b/wsgi_frontoffice.py new file mode 100644 index 0000000..cdc51cd --- /dev/null +++ b/wsgi_frontoffice.py @@ -0,0 +1,9 @@ +"""WSGI entry point for the frontoffice (port 8080).""" +from app import create_app + +application = create_app("frontoffice") + +if __name__ == "__main__": + import os + port = int(os.environ.get("FRONTOFFICE_PORT", "8080")) + application.run(host="0.0.0.0", port=port, debug=False)