v_2.3
This commit is contained in:
@@ -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/
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
data/
|
||||
*.db
|
||||
*.sqlite*
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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://<your-host>: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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<int:user_id>", 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"))
|
||||
@@ -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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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.",
|
||||
"<p>This is a <strong>test email</strong> from AlertHub. If you received this, SMTP is configured correctly.</p>",
|
||||
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.",
|
||||
"<p>Attached is the <strong>AlertHub audit log</strong> export requested from the backoffice.</p>",
|
||||
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.",
|
||||
"<p>Attached is the <strong>AlertHub mail log</strong> export requested from the backoffice.</p>",
|
||||
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"))
|
||||
@@ -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/<int:alert_id>/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/<int:alert_id>/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/<int:alert_id>/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"))
|
||||
@@ -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"""
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 600px;">
|
||||
<h2 style="color: #00d4aa;">⏰ AlertHub Reminder</h2>
|
||||
<h3>{alert.title}</h3>
|
||||
<p><strong>Category:</strong> {alert.category or '—'}<br>
|
||||
<strong>Expiration date:</strong> {alert.expiration_date.isoformat()}<br>
|
||||
<strong>Days remaining:</strong> <span style="color:#ff6b6b;font-size:1.2em;">{days_left}</span></p>
|
||||
<p>{alert.description or ''}</p>
|
||||
</div>
|
||||
"""
|
||||
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)
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a0e14"/><path d="M9 9 h3 v14 h-3z M14 9 h3 v14 h-3z" fill="#00d4aa"/><circle cx="22" cy="11" r="2" fill="#ff3e8b"/></svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Account{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// account › security</span>
|
||||
<h1>{{ current_user.username }}</h1>
|
||||
<p class="lead">{{ current_user.email }} · role <span class="role-badge role-{{ current_user.role }}">{{ current_user.role }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel" style="margin-bottom: 20px;">
|
||||
<header class="panel-header"><h3>Profile avatar</h3></header>
|
||||
<div class="panel-body">
|
||||
<div class="avatar-row">
|
||||
<div class="avatar-preview">
|
||||
{% if current_user.has_avatar %}
|
||||
<img src="{{ url_for('auth.avatar', user_id=current_user.id) }}?v={{ range(1,100000)|random }}" alt="Current avatar">
|
||||
{% else %}
|
||||
<span class="avatar-initials">{{ current_user.initials }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<form method="post" action="{{ url_for('auth.avatar_upload') }}" enctype="multipart/form-data" class="form">
|
||||
{{ avatar_form.csrf_token }}
|
||||
<div>{{ avatar_form.avatar.label }}{{ avatar_form.avatar(accept="image/*") }}</div>
|
||||
<div class="help">PNG, JPG, GIF or WebP. Squared and resized to {{ 256 }}×{{ 256 }} automatically. Max 2 MB.</div>
|
||||
<div class="btn-row">
|
||||
{{ avatar_form.submit(class_="btn btn-primary") }}
|
||||
{% if current_user.has_avatar %}
|
||||
<button type="submit" form="avatar-remove-form" class="btn btn-ghost">Remove avatar</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if current_user.has_avatar %}
|
||||
<form method="post" action="{{ url_for('auth.avatar_remove') }}" id="avatar-remove-form" class="inline-form" data-confirm="Remove your avatar?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<section class="panel">
|
||||
<header class="panel-header"><h3>Change password</h3></header>
|
||||
<div class="panel-body">
|
||||
<form method="post" action="{{ url_for('auth.change_password') }}" class="form" novalidate>
|
||||
{{ password_form.csrf_token }}
|
||||
<div>{{ password_form.current_password.label }}{{ password_form.current_password(autocomplete="current-password") }}</div>
|
||||
<div>{{ password_form.new_password.label }}{{ password_form.new_password(autocomplete="new-password") }}</div>
|
||||
<div>{{ password_form.confirm.label }}{{ password_form.confirm(autocomplete="new-password") }}</div>
|
||||
{{ password_form.submit(class_="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-header">
|
||||
<h3>Multi-factor authentication</h3>
|
||||
<span class="status-badge {{ 'status-active' if current_user.mfa_enabled else 'status-inactive' }}">
|
||||
<span class="led"></span>{{ 'enabled' if current_user.mfa_enabled else 'disabled' }}
|
||||
</span>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
{% if current_user.mfa_enabled %}
|
||||
<p>MFA is currently <strong>active</strong> on your account. You'll be prompted for a 6-digit code on every sign-in.</p>
|
||||
<form method="post" action="{{ url_for('auth.mfa_disable') }}" class="inline-form" data-confirm="Disable MFA? This reduces your account security.">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Disable MFA</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>MFA is currently <strong>not configured</strong>. Strongly recommended for admin accounts.</p>
|
||||
<a href="{{ url_for('auth.mfa_setup') }}" class="btn btn-primary">Set up MFA now</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<style>@media (max-width: 720px) { div[style*="grid-template-columns: 1fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit' if alert else 'New' }} alert{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// section 02 › {{ 'edit' if alert else 'new' }}</span>
|
||||
<h1>{{ 'Edit alert' if alert else 'New alert' }}</h1>
|
||||
{% if alert %}<p class="lead">Last updated <span class="mono">{{ alert.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</span> UTC.</p>{% endif %}
|
||||
</div>
|
||||
<a href="{{ url_for('main.alerts_list') }}" class="btn btn-ghost">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="max-width: 720px;">
|
||||
<div class="panel-body">
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div>
|
||||
{{ form.title.label }}{{ form.title(placeholder="e.g. Microsoft 365 Renewal") }}
|
||||
{% for err in form.title.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<div>
|
||||
{{ form.category.label }}{{ form.category(placeholder="License, SSL, Domain, Contract …") }}
|
||||
{% for err in form.category.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.expiration_date.label }}{{ form.expiration_date() }}
|
||||
{% for err in form.expiration_date.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ form.description.label }}{{ form.description(rows=4, placeholder="Optional detail shown on the alert card.") }}
|
||||
{% for err in form.description.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.reminder_days.label }}{{ form.reminder_days() }}
|
||||
<div class="help">Comma-separated days BEFORE expiration when an email reminder fires. Example: <span class="mono">30,14,7,1</span></div>
|
||||
{% for err in form.reminder_days.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="check-row">{{ form.is_active() }} {{ form.is_active.label }}</div>
|
||||
<div class="btn-row">
|
||||
{{ form.submit(class_="btn btn-primary") }}
|
||||
<a href="{{ url_for('main.alerts_list') }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Manage Alerts{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// section 02 › add / remove / edit</span>
|
||||
<h1>Manage alerts</h1>
|
||||
<p class="lead">Create, modify, or retire alerts. Only administrators can make changes.</p>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">+ New alert</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if alerts %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Category</th>
|
||||
<th>Expires</th>
|
||||
<th>Reminders</th>
|
||||
<th>Status</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in alerts %}
|
||||
<tr>
|
||||
<td><strong>{{ a.title }}</strong>{% if a.description %}<br><span class="dim" style="font-size: .82rem;">{{ a.description[:80] }}{% if a.description|length > 80 %}…{% endif %}</span>{% endif %}</td>
|
||||
<td>{% if a.category %}<span class="alert-cat">{{ a.category }}</span>{% else %}<span class="dim">—</span>{% endif %}</td>
|
||||
<td class="mono">{{ a.expiration_date.isoformat() }}</td>
|
||||
<td class="mono dim">{{ a.reminder_days|join(', ') }}d</td>
|
||||
<td>
|
||||
<span class="status-badge {{ 'status-active' if a.is_active else 'status-inactive' }}">
|
||||
<span class="led"></span>{{ 'active' if a.is_active else 'inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('main.alert_edit', alert_id=a.id) }}" class="btn btn-ghost btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('main.alert_reset_reminders', alert_id=a.id) }}" class="inline-form" data-confirm="Re-send any pending reminders that were already sent?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Reset reminders</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.alert_delete', alert_id=a.id) }}" class="inline-form" data-confirm="Permanently delete '{{ a.title }}'?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="dim">view only</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<p><strong>No alerts yet.</strong></p>
|
||||
{% if current_user.is_admin %}<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">Create one</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit log{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// console › audit log</span>
|
||||
<h1>Audit log</h1>
|
||||
<p class="lead">Sign-ins, configuration changes, and alert mutations. Most-recent first.</p>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
<a href="{{ url_for('backoffice.audit_log_export') }}" class="btn btn-ghost">⬇ Export CSV</a>
|
||||
<form method="post" action="{{ url_for('backoffice.audit_log_email') }}" class="log-email-form" novalidate>
|
||||
{{ log_email_form.csrf_token }}
|
||||
{{ log_email_form.log_recipient(placeholder="destination@example.com") }}
|
||||
{{ log_email_form.submit(class_="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Timestamp (UTC)</th><th>User</th><th>Action</th><th>Details</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in pagination.items %}
|
||||
<tr>
|
||||
<td class="mono dim" style="font-size: .82rem; white-space: nowrap;">{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>{{ e.username or '—' }}</td>
|
||||
<td><span class="mono" style="color: var(--accent);">{{ e.action }}</span></td>
|
||||
<td class="dim">{{ e.details or '' }}</td>
|
||||
<td class="mono dim">{{ e.ip_address or '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="dim" style="text-align: center; padding: 32px;">No audit entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if pagination.has_prev %}<a href="{{ url_for('backoffice.audit_log', page=pagination.prev_num) }}">‹ prev</a>{% endif %}
|
||||
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||
{% else %}<a href="{{ url_for('backoffice.audit_log', page=p) }}">{{ p }}</a>{% endif %}
|
||||
{% else %}<span>…</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}<a href="{{ url_for('backoffice.audit_log', page=pagination.next_num) }}">next ›</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// console › overview</span>
|
||||
<h1>Operations dashboard</h1>
|
||||
<p class="lead">System status as of <span class="mono">{{ today.isoformat() }}</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat stat-accent">
|
||||
<div class="stat-label">Active alerts</div>
|
||||
<div class="stat-value">{{ active_alerts }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Users</div>
|
||||
<div class="stat-value">{{ user_count }}</div>
|
||||
</div>
|
||||
<div class="stat stat-warn">
|
||||
<div class="stat-label">Administrators</div>
|
||||
<div class="stat-value">{{ admin_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: 1.2fr 1fr; gap: 20px;">
|
||||
<section class="panel">
|
||||
<header class="panel-header">
|
||||
<h3>Expiring soonest</h3>
|
||||
<a href="{{ url_for('backoffice.settings') }}" class="muted mono" style="font-size: .82rem;">settings →</a>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
{% if expiring_soon %}
|
||||
<table class="table" style="border: none;">
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Expires</th><th>Days</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in expiring_soon %}
|
||||
{% set days = (a.expiration_date - today).days %}
|
||||
<tr>
|
||||
<td><strong>{{ a.title }}</strong>{% if a.category %} <span class="alert-cat">{{ a.category }}</span>{% endif %}</td>
|
||||
<td class="mono">{{ a.expiration_date.isoformat() }}</td>
|
||||
<td class="mono" style="color: {% if days < 7 %}var(--danger){% elif days < 30 %}var(--amber){% else %}var(--accent){% endif %};">{{ days }}d</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="dim">No upcoming alerts.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-header"><h3>Recent activity</h3>
|
||||
<a href="{{ url_for('backoffice.audit_log') }}" class="muted mono" style="font-size: .82rem;">full log →</a>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
{% if recent_audit %}
|
||||
<ul style="list-style: none; padding: 0; margin: 0; display: grid; gap: 8px;">
|
||||
{% for entry in recent_audit %}
|
||||
<li class="mono" style="font-size: .82rem; display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
||||
<span><span style="color: var(--accent);">{{ entry.action }}</span> {% if entry.username %}<span class="dim">· {{ entry.username }}</span>{% endif %}{% if entry.details %}<br><span class="dim">{{ entry.details }}</span>{% endif %}</span>
|
||||
<span class="dim" style="white-space: nowrap;">{{ entry.timestamp.strftime('%m-%d %H:%M') }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="dim">No activity yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1.2fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mail configuration{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// console › mail server</span>
|
||||
<h1>SMTP configuration</h1>
|
||||
<p class="lead">External mail server used to dispatch alert reminders. Edits take effect immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px;">
|
||||
<section class="panel">
|
||||
<header class="panel-header"><h3>Server settings</h3></header>
|
||||
<div class="panel-body">
|
||||
<form method="post" action="{{ url_for('backoffice.mail_config') }}" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div class="form-grid-2">
|
||||
<div style="grid-column: span 2;">
|
||||
{{ form.smtp_host.label }}{{ form.smtp_host(placeholder="smtp.example.com") }}
|
||||
{% for err in form.smtp_host.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.smtp_port.label }}{{ form.smtp_port() }}
|
||||
{% for err in form.smtp_port.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.smtp_encryption.label }}{{ form.smtp_encryption() }}
|
||||
<div class="help">587 → STARTTLS · 465 → SSL/TLS · 25 → none</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ form.smtp_username.label }}{{ form.smtp_username(autocomplete="off") }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.smtp_password.label }}{{ form.smtp_password(autocomplete="new-password") }}
|
||||
<div class="help">Leave blank to keep the current password.</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ form.smtp_from_address.label }}{{ form.smtp_from_address() }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.smtp_from_name.label }}{{ form.smtp_from_name() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h4 style="margin: 4px 0 2px; font-family: var(--font-mono); color: var(--text-secondary);">Sent-folder copy (IMAP)</h4>
|
||||
<p class="help" style="margin-top: 0;">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.</p>
|
||||
<div class="check-row" style="margin-bottom: 12px;">{{ form.imap_enabled() }} {{ form.imap_enabled.label }}</div>
|
||||
<div class="form-grid-2">
|
||||
<div style="grid-column: span 2;">
|
||||
{{ form.imap_host.label }}{{ form.imap_host(placeholder="imap.purelymail.com") }}
|
||||
{% for err in form.imap_host.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.imap_port.label }}{{ form.imap_port(placeholder="993") }}
|
||||
{% for err in form.imap_port.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.imap_sent_folder.label }}{{ form.imap_sent_folder(placeholder="Sent") }}
|
||||
<div class="help">Usually <code>Sent</code>.</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ form.imap_username.label }}{{ form.imap_username(autocomplete="off") }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.imap_password.label }}{{ form.imap_password(autocomplete="new-password") }}
|
||||
<div class="help">Blank keeps current; if never set, the SMTP password is used.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form.submit(class_="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-header"><h3>Send test email</h3></header>
|
||||
<div class="panel-body">
|
||||
<p class="muted" style="font-size: .9rem;">Confirms credentials, port, and encryption are correct.</p>
|
||||
<form method="post" action="{{ url_for('backoffice.mail_test') }}" class="form" novalidate>
|
||||
{{ test_form.csrf_token }}
|
||||
<div>{{ test_form.test_recipient.label }}{{ test_form.test_recipient(placeholder="you@example.com") }}</div>
|
||||
{{ test_form.submit(class_="btn btn-ghost btn-block") }}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1.4fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mail log{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// console › mail log</span>
|
||||
<h1>Mail log</h1>
|
||||
<p class="lead">Every message the platform has dispatched, whether a copy reached the Sent folder, and its delivery result. Most-recent first.</p>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
<a href="{{ url_for('backoffice.mail_log_export') }}" class="btn btn-ghost">⬇ Export CSV</a>
|
||||
<form method="post" action="{{ url_for('backoffice.mail_log_email') }}" class="log-email-form" novalidate>
|
||||
{{ log_email_form.csrf_token }}
|
||||
{{ log_email_form.log_recipient(placeholder="destination@example.com") }}
|
||||
{{ log_email_form.submit(class_="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp (UTC)</th><th>Kind</th><th>Recipients</th><th>Subject</th>
|
||||
<th>Status</th><th>Sent copy</th><th>By</th><th>Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in pagination.items %}
|
||||
<tr>
|
||||
<td class="mono dim" style="font-size: .82rem; white-space: nowrap;">{{ m.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td><span class="mono" style="color: var(--info);">{{ m.kind }}</span></td>
|
||||
<td class="dim" style="font-size: .85rem;">{{ m.recipients or '—' }}</td>
|
||||
<td>{{ m.subject or '—' }}</td>
|
||||
<td>
|
||||
{% if m.status == 'sent' %}
|
||||
<span class="status-badge status-active"><span class="led"></span>sent</span>
|
||||
{% else %}
|
||||
<span class="status-badge" style="color: var(--danger);"><span class="led" style="background: var(--danger);"></span>failed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.sent_copy == 'saved' %}
|
||||
<span class="mono" style="color: var(--accent);">saved</span>
|
||||
{% elif m.sent_copy == 'failed' %}
|
||||
<span class="mono" style="color: var(--danger);">failed</span>
|
||||
{% elif m.sent_copy == 'disabled' %}
|
||||
<span class="mono dim">disabled</span>
|
||||
{% else %}
|
||||
<span class="mono dim">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono dim" style="font-size: .82rem;">{{ m.triggered_by or 'system' }}</td>
|
||||
<td class="dim" style="font-size: .8rem;">{{ m.info or '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="dim" style="text-align: center; padding: 32px;">No mail has been sent yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if pagination.has_prev %}<a href="{{ url_for('backoffice.mail_log', page=pagination.prev_num) }}">‹ prev</a>{% endif %}
|
||||
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||
{% else %}<a href="{{ url_for('backoffice.mail_log', page=p) }}">{{ p }}</a>{% endif %}
|
||||
{% else %}<span>…</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}<a href="{{ url_for('backoffice.mail_log', page=pagination.next_num) }}">next ›</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// console › settings</span>
|
||||
<h1>Global settings</h1>
|
||||
<p class="lead">Default reminder cadence and scheduler controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<section class="panel">
|
||||
<header class="panel-header"><h3>Default reminder schedule</h3></header>
|
||||
<div class="panel-body">
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div>
|
||||
{{ form.default_reminder_days.label }}{{ form.default_reminder_days() }}
|
||||
<div class="help">When creating a new alert, this is the default. Existing alerts are unaffected.</div>
|
||||
{% for err in form.default_reminder_days.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
{{ form.submit(class_="btn btn-primary") }}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-header"><h3>Scheduler</h3></header>
|
||||
<div class="panel-body">
|
||||
<p>Reminder dispatch runs every <strong class="mono" style="color: var(--accent);">{{ scheduler_interval }}</strong> minute(s).</p>
|
||||
<p class="muted" style="font-size: .9rem;">Configured via the <code>SCHEDULER_INTERVAL_MINUTES</code> environment variable. Restart the container to change it.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>You can trigger a check immediately to verify reminders dispatch correctly.</p>
|
||||
<form method="post" action="{{ url_for('backoffice.run_check_now') }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-ghost" type="submit">▶ Run reminder check now</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit' if user else 'New' }} user{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// section 03 › {{ 'edit' if user else 'new' }} user</span>
|
||||
<h1>{{ 'Edit user' if user else 'New user' }}</h1>
|
||||
{% if user %}<p class="lead">Created <span class="mono">{{ user.created_at.strftime('%Y-%m-%d') }}</span></p>{% endif %}
|
||||
</div>
|
||||
<a href="{{ url_for('backoffice.users_list') }}" class="btn btn-ghost">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="max-width: 640px;">
|
||||
<div class="panel-body">
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div class="form-grid-2">
|
||||
<div>{{ form.username.label }}{{ form.username() }}
|
||||
{% for err in form.username.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>{{ form.email.label }}{{ form.email() }}
|
||||
{% for err in form.email.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<div>{{ form.role.label }}{{ form.role() }}</div>
|
||||
<div class="check-row" style="align-self: end;">{{ form.is_active() }} {{ form.is_active.label }}</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ form.password.label }}{{ form.password(autocomplete="new-password") }}
|
||||
<div class="help">{% if user %}Leave blank to keep the existing password.{% else %}Min {{ config.PASSWORD_MIN_LENGTH }} characters.{% endif %}</div>
|
||||
{% for err in form.password.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{{ form.submit(class_="btn btn-primary") }}
|
||||
<a href="{{ url_for('backoffice.users_list') }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// section 03 › users & permissions</span>
|
||||
<h1>Users</h1>
|
||||
<p class="lead">Manage accounts, roles, MFA, and lockouts.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('backoffice.user_new') }}" class="btn btn-primary">+ New user</a>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th><th>Email</th><th>Role</th><th>MFA</th><th>Status</th><th>Last login</th><th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong>{% if u.id == current_user.id %} <span class="dim mono" style="font-size: .72rem;">(you)</span>{% endif %}</td>
|
||||
<td class="mono dim" style="font-size: .85rem;">{{ u.email }}</td>
|
||||
<td><span class="role-badge role-{{ u.role }}">{{ u.role }}</span></td>
|
||||
<td>
|
||||
<span class="status-badge {{ 'status-active' if u.mfa_enabled else 'status-inactive' }}">
|
||||
<span class="led"></span>{{ 'on' if u.mfa_enabled else 'off' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_locked %}
|
||||
<span class="status-badge" style="color: var(--danger);"><span class="led" style="background: var(--danger);"></span>locked</span>
|
||||
{% else %}
|
||||
<span class="status-badge {{ 'status-active' if u.is_active else 'status-inactive' }}"><span class="led"></span>{{ 'active' if u.is_active else 'disabled' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono dim" style="font-size: .82rem;">
|
||||
{% if u.last_login_at %}{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') }}{% else %}never{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('backoffice.user_edit', user_id=u.id) }}" class="btn btn-ghost btn-sm">Edit</a>
|
||||
{% if u.mfa_enabled %}
|
||||
<form method="post" action="{{ url_for('backoffice.user_reset_mfa', user_id=u.id) }}" class="inline-form" data-confirm="Reset MFA for {{ u.username }}?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Reset MFA</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if u.is_locked %}
|
||||
<form method="post" action="{{ url_for('backoffice.user_unlock', user_id=u.id) }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Unlock</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if u.id != current_user.id %}
|
||||
<form method="post" action="{{ url_for('backoffice.user_delete', user_id=u.id) }}" class="inline-form" data-confirm="Permanently delete user '{{ u.username }}'?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>{% block title %}{{ brand_name }}{% endblock %} — {{ brand_name }}{% if is_backoffice %} · backoffice{% endif %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
<script>
|
||||
// Theme bootstrap — set BEFORE first paint to avoid flash
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('alerthub-theme');
|
||||
var theme = saved || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="{{ 'is-backoffice' if is_backoffice else 'is-frontoffice' }}">
|
||||
|
||||
<div class="grid-bg" aria-hidden="true"></div>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<a class="brand" href="{{ url_for('main.index') if not is_backoffice else url_for('backoffice.dashboard') }}">
|
||||
<span class="brand-mark">▮▮</span>
|
||||
<span class="brand-name">{{ brand_name }}</span>
|
||||
<span class="brand-tag">{{ 'BACKOFFICE' if is_backoffice else 'FRONTOFFICE' }}</span>
|
||||
</a>
|
||||
|
||||
{% if current_user.is_authenticated and not session.get('mfa_pending') %}
|
||||
<nav class="topbar-nav">
|
||||
{% if not is_backoffice %}
|
||||
<a href="{{ url_for('main.index') }}" class="navlink {% if request.endpoint == 'main.index' %}active{% endif %}">
|
||||
<span class="dot"></span>View
|
||||
</a>
|
||||
<a href="{{ url_for('main.alerts_list') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('main.alert') %}active{% endif %}">
|
||||
<span class="dot"></span>Manage Alerts
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('backoffice.dashboard') }}" class="navlink navlink-cross {% if is_backoffice %}active{% endif %}" title="Open the admin backoffice">
|
||||
<span class="dot dot-admin"></span>Backoffice
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{{ url_for('main.index') }}" class="navlink navlink-cross" title="Back to the alerts frontoffice">
|
||||
<span class="dot"></span>Frontoffice
|
||||
</a>
|
||||
<a href="{{ url_for('backoffice.dashboard') }}" class="navlink {% if request.endpoint == 'backoffice.dashboard' %}active{% endif %}">
|
||||
<span class="dot"></span>Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('backoffice.users_list') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('backoffice.user') %}active{% endif %}">
|
||||
<span class="dot"></span>Users
|
||||
</a>
|
||||
<a href="{{ url_for('backoffice.mail_config') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('backoffice.mail') %}active{% endif %}">
|
||||
<span class="dot"></span>Mail
|
||||
</a>
|
||||
<a href="{{ url_for('backoffice.settings') }}" class="navlink {% if request.endpoint == 'backoffice.settings' %}active{% endif %}">
|
||||
<span class="dot"></span>Settings
|
||||
</a>
|
||||
<a href="{{ url_for('backoffice.audit_log') }}" class="navlink {% if request.endpoint == 'backoffice.audit_log' %}active{% endif %}">
|
||||
<span class="dot"></span>Audit
|
||||
</a>
|
||||
<a href="{{ url_for('backoffice.mail_log') }}" class="navlink {% if request.endpoint == 'backoffice.mail_log' %}active{% endif %}">
|
||||
<span class="dot"></span>Mail Log
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
|
||||
<a href="{{ url_for('auth.account') }}" class="user-chip" title="Account">
|
||||
{% if current_user.has_avatar %}
|
||||
<img class="user-avatar user-avatar-img" src="{{ url_for('auth.avatar', user_id=current_user.id) }}" alt="{{ current_user.username }}">
|
||||
{% else %}
|
||||
<span class="user-avatar">{{ current_user.initials }}</span>
|
||||
{% endif %}
|
||||
<span class="user-meta">
|
||||
<span class="user-name">{{ current_user.username }}</span>
|
||||
<span class="user-role role-{{ current_user.role }}">{{ current_user.role }}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-ghost" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="topbar-right">
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="flash flash-{{ cat }}">
|
||||
<span class="flash-icon"></span>
|
||||
<span class="flash-text">{{ msg }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% if request.endpoint != 'auth.login' %}
|
||||
<footer class="footer">
|
||||
<span class="kbd">{{ brand_name }}</span>
|
||||
<span class="footer-sep">·</span>
|
||||
<span>{{ 'admin console' if is_backoffice else 'alert console' }}</span>
|
||||
<span class="footer-copy">{{ footer_text }}</span>
|
||||
<span class="status-pill"><span class="led"></span>operational</span>
|
||||
</footer>
|
||||
{% endif %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ code }} · Error{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card" style="text-align: center;">
|
||||
<div class="auth-prompt">error</div>
|
||||
<h1 style="font-family: var(--font-mono); font-size: 3rem; color: var(--magenta);">{{ code }}</h1>
|
||||
<p class="auth-sub">{{ message }}</p>
|
||||
<a href="{{ url_for('main.index') if not is_backoffice else url_for('backoffice.dashboard') }}" class="btn btn-primary">← Return home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}View · Alerts{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// section 01 › view</span>
|
||||
<h1>Active alerts</h1>
|
||||
<p class="lead">Live countdown to expiration. Reminders are dispatched automatically by email per the configured schedule.</p>
|
||||
</div>
|
||||
<div class="row-gap">
|
||||
<span class="kbd">today: <strong style="color: var(--accent);">{{ today_iso }}</strong></span>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">+ New alert</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if alerts %}
|
||||
<div class="alerts-grid">
|
||||
{% 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')) %}
|
||||
<article class="alert-card {{ state }}">
|
||||
<header class="alert-head">
|
||||
<h3 class="alert-title">{{ a.title }}</h3>
|
||||
{% if a.category %}<span class="alert-cat">{{ a.category }}</span>{% endif %}
|
||||
</header>
|
||||
{% if a.description %}<p class="alert-desc">{{ a.description }}</p>{% endif %}
|
||||
|
||||
<div class="alert-message">
|
||||
<strong>{{ a.title }}</strong> · expires {{ a.expiration_date }}
|
||||
</div>
|
||||
|
||||
<div class="countdown" data-expires="{{ a.expiration_date }}">
|
||||
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">days</span></div>
|
||||
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">hours</span></div>
|
||||
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">min</span></div>
|
||||
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">sec</span></div>
|
||||
</div>
|
||||
|
||||
<div class="alert-meta">
|
||||
<span><span class="alert-status-dot"></span><span class="alert-state-text">{{ state_text }}</span></span>
|
||||
<span>reminders: {{ a.reminder_days|join(', ') }}d</span>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<div class="empty-icon">▮▮</div>
|
||||
<p><strong>No active alerts.</strong></p>
|
||||
{% if current_user.is_admin %}
|
||||
<p class="dim">Add your first one — license renewals, certificates, contracts, …</p>
|
||||
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">Create the first alert</a>
|
||||
{% else %}
|
||||
<p class="dim">Ask an administrator to create alerts for your team.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sign in{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-prompt">authenticate</div>
|
||||
<h1>Sign in to {{ brand_name }}</h1>
|
||||
<p class="auth-sub">{% if is_backoffice %}Administrator console.{% else %}Alert operations console.{% endif %}</p>
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div>
|
||||
{{ form.username.label }}
|
||||
{{ form.username(autocomplete="username", autofocus=True) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.password.label }}
|
||||
{{ form.password(autocomplete="current-password") }}
|
||||
</div>
|
||||
{{ form.submit(class_="btn btn-primary btn-block") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Verify identity{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-prompt">verify</div>
|
||||
<h1>Two-factor authentication</h1>
|
||||
<p class="auth-sub">Enter the 6-digit code from your authenticator app.</p>
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div>
|
||||
{{ form.code.label }}
|
||||
{{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", autofocus=True, maxlength=6) }}
|
||||
</div>
|
||||
{{ form.submit(class_="btn btn-primary btn-block") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Set up MFA{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-tag">// account › mfa</span>
|
||||
<h1>Enable multi-factor authentication</h1>
|
||||
<p class="lead">Scan the QR code with your authenticator (Google Authenticator, Authy, 1Password, …) and enter the 6-digit code to confirm.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="max-width: 540px; margin: 0 auto;">
|
||||
<div class="panel-body">
|
||||
<img src="data:image/png;base64,{{ qr_b64 }}" alt="MFA QR code" class="mfa-qr">
|
||||
<p class="dim" style="text-align:center; font-size: .82rem; margin: 16px 0 4px;">Manual key (if you can't scan):</p>
|
||||
<div class="mfa-secret">{{ secret }}</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div>
|
||||
{{ form.code.label }}
|
||||
{{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", maxlength=6) }}
|
||||
<div class="help">Enter the current 6-digit code shown in your authenticator.</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{{ form.submit(class_="btn btn-primary") }}
|
||||
<a href="{{ url_for('auth.account') }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Initial setup{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-prompt">first-run setup</div>
|
||||
<h1>Create administrator</h1>
|
||||
<p class="auth-sub">No accounts exist yet. Create the first administrator to secure {{ brand_name }}.</p>
|
||||
<form method="post" class="form" novalidate>
|
||||
{{ form.csrf_token }}
|
||||
<div>
|
||||
{{ form.username.label }}
|
||||
{{ form.username(autocomplete="username", autofocus=True) }}
|
||||
{% for err in form.username.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.email.label }}
|
||||
{{ form.email(autocomplete="email") }}
|
||||
{% for err in form.email.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.password.label }}
|
||||
{{ form.password(autocomplete="new-password") }}
|
||||
{% for err in form.password.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.confirm.label }}
|
||||
{{ form.confirm(autocomplete="new-password") }}
|
||||
{% for err in form.confirm.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
{{ form.submit(class_="btn btn-primary btn-block") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user