This commit is contained in:
jpmvaz
2026-09-13 20:10:17 +01:00
parent 9b5cc30fb4
commit b192fb3f17
482 changed files with 0 additions and 48205 deletions
View File
-19
View File
@@ -1,19 +0,0 @@
.git
.gitignore
.env
*.md
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
.env.local
data/
*.db
*.sqlite*
.DS_Store
.idea/
.vscode/
node_modules/
tests/
-79
View File
@@ -1,79 +0,0 @@
# =============================================================================
# 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
-11
View File
@@ -1,11 +0,0 @@
.env
*.pyc
__pycache__/
.venv/
venv/
data/
*.db
*.sqlite*
.DS_Store
.idea/
.vscode/
-45
View File
@@ -1,45 +0,0 @@
# 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.
-144
View File
@@ -1,144 +0,0 @@
# 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.
-278
View File
@@ -1,278 +0,0 @@
"""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
-37
View File
@@ -1,37 +0,0 @@
"""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 ""
-81
View File
@@ -1,81 +0,0 @@
"""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
-139
View File
@@ -1,139 +0,0 @@
"""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")
-161
View File
@@ -1,161 +0,0 @@
"""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
-192
View File
@@ -1,192 +0,0 @@
"""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
-308
View File
@@ -1,308 +0,0 @@
"""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"))
-426
View File
@@ -1,426 +0,0 @@
"""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"))
-137
View File
@@ -1,137 +0,0 @@
"""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"))
-79
View File
@@ -1,79 +0,0 @@
"""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)
-869
View File
@@ -1,869 +0,0 @@
/* ======================================================================
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; }
}
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 226 B

-73
View File
@@ -1,73 +0,0 @@
// ====== 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();
});
});
@@ -1,81 +0,0 @@
{% 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&nbsp;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 %}
@@ -1,48 +0,0 @@
{% 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 %}
@@ -1,64 +0,0 @@
{% 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 %}
@@ -1,51 +0,0 @@
{% 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 %}
@@ -1,77 +0,0 @@
{% 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 %}
@@ -1,90 +0,0 @@
{% 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 %}
@@ -1,73 +0,0 @@
{% 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 %}
@@ -1,45 +0,0 @@
{% 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 %}
@@ -1,41 +0,0 @@
{% 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 %}
@@ -1,65 +0,0 @@
{% 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 %}
-136
View File
@@ -1,136 +0,0 @@
<!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>
-12
View File
@@ -1,12 +0,0 @@
{% 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 %}
-60
View File
@@ -1,60 +0,0 @@
{% 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 %}
-23
View File
@@ -1,23 +0,0 @@
{% 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 %}
-19
View File
@@ -1,19 +0,0 @@
{% 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 %}
@@ -1,34 +0,0 @@
{% 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 %}
-35
View File
@@ -1,35 +0,0 @@
{% 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 %}
-34
View File
@@ -1,34 +0,0 @@
# 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
-26
View File
@@ -1,26 +0,0 @@
#!/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"
-14
View File
@@ -1,14 +0,0 @@
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
-13
View File
@@ -1,13 +0,0 @@
"""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)
-9
View File
@@ -1,9 +0,0 @@
"""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)
-9
View File
@@ -1,9 +0,0 @@
"""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)
-18
View File
@@ -1,18 +0,0 @@
# NOTE: Docker Compose treats '$' in this file as variable substitution.
# If any value (e.g. a password) contains a literal '$', write it as '$$':
# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2
# Otherwise you'll see a warning like: The "..." variable is not set.
# --- Web app ---
SECRET_KEY=change-me-to-a-long-random-string
BASE_URL=http://localhost:8000 # public URL used in approve/deny links
PORT=8000
ADMIN_USERNAME=admin # created on first run only
ADMIN_PASSWORD=changeme
# --- Purelymail account (only login and password are needed) ---
# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS.
# If Two Factor Authentication is enabled on the account, use an App Password.
# Leave MAIL_USER empty to print outgoing mail to the console (dev mode).
MAIL_USER=you@yourdomain.com
MAIL_PASSWORD=secret
-9
View File
@@ -1,9 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATABASE_PATH=/data/approvalflow.db
VOLUME /data
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
Binary file not shown.
@@ -1,21 +0,0 @@
# --- Web app ---
SECRET_KEY=change-me-to-a-long-random-string
BASE_URL=http://localhost:8000 # public URL used in approve/deny links
PORT=8000
ADMIN_USERNAME=admin # created on first run only
ADMIN_PASSWORD=changeme
# --- Inbound email (IMAP) — leave IMAP_HOST empty to disable email intake ---
IMAP_HOST=imap.example.com
IMAP_USER=inbox@example.com
IMAP_PASSWORD=secret
IMAP_FOLDER=INBOX
IMAP_POLL_SECONDS=30
# --- Outbound email (SMTP) — leave SMTP_HOST empty to print mail to console (dev) ---
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls # starttls | ssl | none
SMTP_USER=inbox@example.com
SMTP_PASSWORD=secret
SMTP_FROM=approvals@example.com
@@ -1,9 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATABASE_PATH=/data/approvalflow.db
VOLUME /data
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
@@ -1,79 +0,0 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests arrive by email (or through a login-protected web form), get redirected to an approver, and once approved or denied, **everyone involved up to that point** is notified by email. Every request keeps a full audit log of everything that happened to it.
## What it does
- **Email intake.** An IMAP poller watches a mailbox. When a message arrives from a workflow's allowed domain, a request is created and redirected to that workflow's approver with one-click **Approve** / **Deny** links. Mail from any other domain is rejected (and logged).
- **Decisions notify everyone.** On approve or deny, the requester, anyone CC'd on the original email, and the approver all receive the outcome email.
- **Full log.** Every request records: received, matched workflow, approval requested, decision, notifications sent, and any mail errors — each with a timestamp.
- **Multiple named workflows**, each personalized: its own allowed sender domain, approver, and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`.
- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser.
- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows.
## Quick start (Docker)
```bash
cp .env.example .env # edit it: SECRET_KEY, BASE_URL, IMAP_*, SMTP_*
docker compose up -d
```
Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`.
## Quick start (bare Python)
```bash
pip install -r requirements.txt
cp .env.example .env
set -a; source .env; set +a # or export the variables another way
python app.py
```
Leaving `SMTP_HOST` empty prints outgoing mail to the console and leaving `IMAP_HOST` empty disables email intake — handy for trying the app before wiring up a mailbox.
## How email intake targets a workflow
1. **Plus-addressing:** mail sent to `inbox+<workflow-slug>@your-domain` targets that workflow.
2. **Subject tag:** a subject containing `[<workflow-slug>]`.
3. **Fallback:** the first active workflow whose allowed domain matches the sender.
Whichever way a workflow is matched, the sender's domain must equal the workflow's allowed domain or the request is marked **rejected** and logged.
## The request lifecycle
```
email / web form
│ received + logged
approval request → approver (approve/deny links)
│ logged
approver clicks a link (or admin decides in the UI)
│ decision logged
APPROVED or DENIED notice → everyone involved so far
(requester + CCs + approver) logged
```
## Personalization placeholders
Usable in every workflow's subject and body templates:
`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{decided_by}`, `{approve_url}`, `{deny_url}`.
## Security notes
- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets.
- Decision links are unguessable random tokens and work only once; a second click shows "already decided."
- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately.
- Non-admin users only ever see and use the workflows they've been granted.
## Project layout
```
app.py Flask routes, auth, admin pages
db.py SQLite schema + audit-log helpers
pipeline.py approval request + decision/notification logic
poller.py IMAP inbox watcher (background thread)
mailer.py SMTP sending + template rendering
templates/ pages static/style.css styling
```
@@ -1,284 +0,0 @@
"""ApprovalFlow — self-hosted email approval workflows."""
import functools
import json
import os
import re
import secrets
from flask import (Flask, abort, flash, g, redirect, render_template,
request, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
import db
import pipeline
import poller
from db import DEFAULT_TEMPLATES
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# ---------------------------------------------------------------- helpers
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return s or "workflow"
def login_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return view(*a, **kw)
return wrapped
def admin_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
if not session.get("is_admin"):
abort(403)
return view(*a, **kw)
return wrapped
@app.before_request
def open_db():
g.db = db.get_db()
@app.teardown_request
def close_db(exc):
d = g.pop("db", None)
if d:
d.close()
def user_workflows(user_id):
if session.get("is_admin"):
return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall()
return g.db.execute(
"SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id "
"WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall()
# ---------------------------------------------------------------- auth
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = g.db.execute("SELECT * FROM users WHERE username=?",
(request.form.get("username", "").strip(),)).fetchone()
if user and check_password_hash(user["password_hash"], request.form.get("password", "")):
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
return redirect(request.args.get("next") or url_for("index"))
flash("Wrong username or password.", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- main page
@app.route("/", methods=["GET", "POST"])
@login_required
def index():
workflows = user_workflows(session["user_id"])
if request.method == "POST":
wf_id = request.form.get("workflow_id", type=int)
wf = next((w for w in workflows if w["id"] == wf_id), None)
if wf is None:
abort(403) # not allowed to use this workflow
subject = request.form.get("subject", "").strip()
body = request.form.get("body", "").strip()
if not subject or not body:
flash("Subject and message are both required.", "error")
else:
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
requester = user["email"] or user["username"]
rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body)
db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}")
g.db.commit()
pipeline.send_approval_request(rid)
flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok")
return redirect(url_for("index"))
my_requests = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15",
(session["username"],
(g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"),
)).fetchall()
return render_template("index.html", workflows=workflows, my_requests=my_requests)
# ---------------------------------------------------------------- decision links (from email)
@app.route("/decision/<token>/<action>", methods=["GET", "POST"])
def decision(token, action):
if action not in ("approve", "deny"):
abort(404)
req = g.db.execute("SELECT r.*, w.name wf_name, w.approver_email FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone()
if req is None:
return render_template("decision.html", stage="notfound"), 404
if request.method == "POST":
decided_by = request.form.get("decided_by", "").strip() or req["approver_email"]
status, req2, message = pipeline.decide(token, action, decided_by)
return render_template("decision.html", stage="done", req=req2, message=message,
action=action, status=status)
return render_template("decision.html", stage="confirm", req=req, action=action)
# ---------------------------------------------------------------- admin: dashboard & requests
@app.route("/admin")
@admin_required
def admin():
rows = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"ORDER BY r.id DESC LIMIT 200").fetchall()
stats = g.db.execute(
"SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
return render_template("admin.html", rows=rows, workflows=workflows,
stats={s["status"]: s["c"] for s in stats})
@app.route("/admin/request/<int:rid>", methods=["GET", "POST"])
@admin_required
def admin_request(rid):
req = g.db.execute("SELECT r.*, w.name wf_name FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
if req is None:
abort(404)
if request.method == "POST" and req["status"] == "pending":
action = request.form.get("action")
if action in ("approve", "deny"):
pipeline.decide(req["token"], action, f"{session['username']} (admin)")
return redirect(url_for("admin_request", rid=rid))
logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall()
participants = json.loads(req["participants"])
return render_template("request_detail.html", req=req, logs=logs, participants=participants)
# ---------------------------------------------------------------- admin: workflows
WF_FIELDS = ("approval_subject", "approval_body", "approved_subject",
"approved_body", "denied_subject", "denied_body")
@app.route("/admin/workflows/new", methods=["GET", "POST"])
@app.route("/admin/workflows/<int:wid>", methods=["GET", "POST"])
@admin_required
def workflow_edit(wid=None):
wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None
if wid and wf is None:
abort(404)
if request.method == "POST":
f = request.form
name = f.get("name", "").strip()
allowed_domain = f.get("allowed_domain", "").strip().lstrip("@").lower()
approver_email = f.get("approver_email", "").strip()
if not (name and allowed_domain and approver_email):
flash("Name, allowed domain and approver email are required.", "error")
else:
templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS}
try:
if wf is None:
g.db.execute(
"INSERT INTO workflows (name, slug, description, allowed_domain, approver_email, active,"
" approval_subject, approval_body, approved_subject, approved_body,"
" denied_subject, denied_body, created_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(name, slugify(name), f.get("description", "").strip(), allowed_domain,
approver_email, 1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], db.now()))
flash(f"Workflow “{name}” created.", "ok")
else:
g.db.execute(
"UPDATE workflows SET name=?, slug=?, description=?, allowed_domain=?,"
" approver_email=?, active=?, approval_subject=?, approval_body=?,"
" approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?",
(name, slugify(name), f.get("description", "").strip(), allowed_domain,
approver_email, 1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], wid))
flash(f"Workflow “{name}” saved.", "ok")
g.db.commit()
return redirect(url_for("admin"))
except Exception as e:
flash(f"Could not save: {e}", "error")
return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES)
# ---------------------------------------------------------------- admin: users & access
@app.route("/admin/users", methods=["GET", "POST"])
@admin_required
def users():
if request.method == "POST":
f = request.form
act = f.get("action")
try:
if act == "create":
if not f.get("username") or not f.get("password"):
flash("Username and password are required.", "error")
else:
g.db.execute(
"INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)",
(f["username"].strip(), generate_password_hash(f["password"]),
f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now()))
flash(f"User “{f['username']}” created.", "ok")
elif act == "delete":
uid = f.get("user_id", type=int)
if uid == session["user_id"]:
flash("You can't delete your own account.", "error")
else:
g.db.execute("DELETE FROM users WHERE id=?", (uid,))
flash("User deleted.", "ok")
elif act == "password":
g.db.execute("UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(f["password"]), f.get("user_id", type=int)))
flash("Password updated.", "ok")
elif act == "access":
uid = f.get("user_id", type=int)
g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,))
for wid in request.form.getlist("workflow_ids", type=int):
g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)",
(uid, wid))
flash("Access updated.", "ok")
g.db.commit()
except Exception as e:
flash(f"Error: {e}", "error")
return redirect(url_for("users"))
all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
access = {}
for row in g.db.execute("SELECT * FROM user_workflows"):
access.setdefault(row["user_id"], set()).add(row["workflow_id"])
return render_template("users.html", users=all_users, workflows=workflows, access=access)
# ---------------------------------------------------------------- boot
db.init_db()
poller.start()
if __name__ == "__main__":
app.run(host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "8000")),
debug=os.environ.get("FLASK_DEBUG") == "1")
@@ -1,157 +0,0 @@
"""SQLite data layer for ApprovalFlow."""
import json
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from werkzeug.security import generate_password_hash
DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
allowed_domain TEXT NOT NULL, -- only senders @this domain are accepted
approver_email TEXT NOT NULL, -- who receives the approval request
active INTEGER NOT NULL DEFAULT 1,
-- personalization (templates support {placeholders})
approval_subject TEXT NOT NULL,
approval_body TEXT NOT NULL,
approved_subject TEXT NOT NULL,
approved_body TEXT NOT NULL,
denied_subject TEXT NOT NULL,
denied_body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_workflows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, workflow_id)
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL REFERENCES workflows(id),
source TEXT NOT NULL, -- 'email' or 'web'
requester TEXT NOT NULL, -- email address (or username for web)
subject TEXT NOT NULL,
body TEXT NOT NULL,
participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far
status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied / rejected
token TEXT UNIQUE NOT NULL, -- secret for approve/deny links
decided_by TEXT DEFAULT '',
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE,
at TEXT NOT NULL,
event TEXT NOT NULL,
detail TEXT DEFAULT ''
);
"""
DEFAULT_TEMPLATES = {
"approval_subject": "[{workflow}] Approval needed: {subject}",
"approval_body": (
"A new request needs your decision.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Approve: {approve_url}\n"
"Deny: {deny_url}\n"
),
"approved_subject": "[{workflow}] Approved: {subject}",
"approved_body": (
"The following request was APPROVED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
"denied_subject": "[{workflow}] Denied: {subject}",
"denied_body": (
"The following request was DENIED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
}
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
return con
def init_db():
con = get_db()
con.executescript(SCHEMA)
# bootstrap admin user on first run
if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0:
username = os.environ.get("ADMIN_USERNAME", "admin")
password = os.environ.get("ADMIN_PASSWORD", "changeme")
con.execute(
"INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)",
(username, generate_password_hash(password), now()),
)
print(f"[init] Created admin user '{username}'. Change the password immediately.")
con.commit()
con.close()
def log(con, request_id, event, detail=""):
con.execute(
"INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)",
(request_id, now(), event, detail),
)
def add_participants(con, request_id, emails):
"""Add people to the 'involved so far' list of a request."""
row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone()
current = json.loads(row["participants"])
for e in emails:
e = (e or "").strip().lower()
if e and e not in current:
current.append(e)
con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id))
return current
def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()):
token = secrets.token_urlsafe(32)
cur = con.execute(
"INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(workflow_id, source, requester, subject, body, token, now()),
)
rid = cur.lastrowid
add_participants(con, rid, [requester, *extra_participants])
log(con, rid, "received", f"Request received via {source} from {requester}")
return rid, token
@@ -1,9 +0,0 @@
services:
approvalflow:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
restart: unless-stopped
@@ -1,54 +0,0 @@
"""Outbound email (SMTP) and template rendering."""
import os
import smtplib
from email.message import EmailMessage
def render(template: str, **vars) -> str:
"""Safe {placeholder} substitution — unknown placeholders are left as-is."""
out = template
for k, v in vars.items():
out = out.replace("{" + k + "}", str(v))
return out
def send_mail(to_addrs, subject, body):
"""Send a plain-text email. Returns (ok, detail)."""
if isinstance(to_addrs, str):
to_addrs = [to_addrs]
to_addrs = [a for a in to_addrs if a]
if not to_addrs:
return False, "no recipients"
host = os.environ.get("SMTP_HOST", "")
port = int(os.environ.get("SMTP_PORT", "587"))
user = os.environ.get("SMTP_USER", "")
password = os.environ.get("SMTP_PASSWORD", "")
sender = os.environ.get("SMTP_FROM", user or "approvalflow@localhost")
security = os.environ.get("SMTP_SECURITY", "starttls").lower() # starttls / ssl / none
msg = EmailMessage()
msg["From"] = sender
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not host:
# Dev mode: print instead of sending so the app is testable without SMTP.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
return True, "dev mode (SMTP_HOST not set) — printed to console"
try:
if security == "ssl":
server = smtplib.SMTP_SSL(host, port, timeout=30)
else:
server = smtplib.SMTP(host, port, timeout=30)
if security == "starttls":
server.starttls()
if user:
server.login(user, password)
server.send_message(msg)
server.quit()
return True, f"sent to {', '.join(to_addrs)}"
except Exception as e:
return False, f"SMTP error: {e}"
@@ -1,76 +0,0 @@
"""Request pipeline shared by the web form and the email poller."""
import os
import db
from mailer import render, send_mail
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
def _vars(wf, req, **extra):
v = {
"workflow": wf["name"],
"requester": req["requester"],
"subject": req["subject"],
"body": req["body"],
"request_id": req["id"],
"approve_url": f"{BASE_URL}/decision/{req['token']}/approve",
"deny_url": f"{BASE_URL}/decision/{req['token']}/deny",
}
v.update(extra)
return v
def send_approval_request(request_id):
"""Redirect the request to the approver, asking for approval."""
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
v = _vars(wf, req)
ok, detail = send_mail(wf["approver_email"],
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v))
db.add_participants(con, request_id, [wf["approver_email"]])
db.log(con, request_id,
"approval requested" if ok else "mail error",
f"Approval request to {wf['approver_email']}{detail}")
con.commit()
return ok
finally:
con.close()
def decide(token, action, decided_by):
"""Approve or deny a request; notify everyone involved so far.
Returns (status, request_row_or_None, message).
"""
assert action in ("approve", "deny")
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone()
if req is None:
return "notfound", None, "This link is not valid."
if req["status"] != "pending":
return "already", req, f"This request was already {req['status']}."
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
new_status = "approved" if action == "approve" else "denied"
con.execute("UPDATE requests SET status=?, decided_by=?, decided_at=? WHERE id=?",
(new_status, decided_by, db.now(), req["id"]))
db.log(con, req["id"], new_status, f"Decision by {decided_by}")
participants = db.add_participants(con, req["id"], [decided_by])
v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper())
subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"]
body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"]
ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v))
db.log(con, req["id"],
"participants notified" if ok else "mail error",
f"{new_status.capitalize()} notice to {', '.join(participants)}{detail}")
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, f"Request #{req['id']} {new_status}. Everyone involved has been notified."
finally:
con.close()
@@ -1,146 +0,0 @@
"""IMAP inbox poller.
Watches a mailbox for new messages and turns them into approval requests.
Workflow matching, in order:
1. Plus-addressing: mail sent to inbox+<workflow-slug>@domain targets that workflow.
2. Subject tag: a subject containing [<workflow-slug>] targets that workflow.
3. Fallback: the first active workflow whose allowed_domain matches the sender.
The sender's domain must match the matched workflow's allowed_domain,
otherwise the message is logged and rejected.
"""
import email
import email.utils
import imaplib
import os
import re
import threading
import time
import traceback
import db
import pipeline
def _addr_list(msg, header):
raw = msg.get_all(header, [])
return [a.lower() for _, a in email.utils.getaddresses(raw) if a]
def _body_text(msg):
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and not part.get("Content-Disposition"):
try:
return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "replace")
except Exception:
continue
return "(no readable text body)"
payload = msg.get_payload(decode=True)
if payload is None:
return str(msg.get_payload())
return payload.decode(msg.get_content_charset() or "utf-8", "replace")
def _match_workflow(con, sender, recipients, subject):
workflows = con.execute("SELECT * FROM workflows WHERE active=1").fetchall()
by_slug = {w["slug"]: w for w in workflows}
# 1. plus-addressing: inbox+slug@...
for addr in recipients:
m = re.match(r"^[^+@]+\+([a-z0-9\-]+)@", addr)
if m and m.group(1) in by_slug:
return by_slug[m.group(1)], "plus-address"
# 2. subject tag [slug]
for slug, w in by_slug.items():
if f"[{slug}]" in subject.lower():
return w, "subject tag"
# 3. sender-domain fallback
domain = sender.split("@")[-1].lower() if "@" in sender else ""
for w in workflows:
if w["allowed_domain"].lower().lstrip("@") == domain:
return w, "sender domain"
return None, None
def process_message(raw_bytes):
msg = email.message_from_bytes(raw_bytes)
sender = (email.utils.parseaddr(msg.get("From", ""))[1] or "").lower()
recipients = _addr_list(msg, "To") + _addr_list(msg, "Cc")
subject = msg.get("Subject", "(no subject)")
body = _body_text(msg).strip()
con = db.get_db()
try:
wf, how = _match_workflow(con, sender, recipients, subject)
if wf is None:
print(f"[poller] No workflow matched mail from {sender} — ignored.")
return
sender_domain = sender.split("@")[-1] if "@" in sender else ""
allowed = wf["allowed_domain"].lower().lstrip("@")
if sender_domain != allowed:
rid, _ = db.create_request(con, wf["id"], "email", sender, subject, body)
con.execute("UPDATE requests SET status='rejected' WHERE id=?", (rid,))
db.log(con, rid, "rejected",
f"Sender domain '{sender_domain}' is not the allowed domain '@{allowed}' for workflow '{wf['name']}'")
con.commit()
print(f"[poller] Rejected mail from {sender} for workflow {wf['name']} (wrong domain).")
return
# CC'd people are "involved up to this point" too — everyone on To/Cc
# except the sender and the intake mailbox itself (incl. its +tag variants)
inbox_local = (os.environ.get("IMAP_USER") or "").split("@")[0].lower()
def is_intake(addr):
base = addr.split("@")[0].split("+")[0]
return (inbox_local and base == inbox_local) or \
addr.split("@")[0].endswith("+" + wf["slug"])
ccs = [a for a in recipients if a != sender and not is_intake(a)]
rid, token = db.create_request(con, wf["id"], "email", sender, subject, body, extra_participants=ccs)
db.log(con, rid, "matched", f"Matched workflow '{wf['name']}' via {how}")
con.commit()
pipeline.send_approval_request(rid)
print(f"[poller] Request #{rid} created for workflow '{wf['name']}' from {sender}.")
except Exception:
traceback.print_exc()
finally:
con.close()
def poll_loop():
host = os.environ.get("IMAP_HOST", "")
user = os.environ.get("IMAP_USER", "")
password = os.environ.get("IMAP_PASSWORD", "")
folder = os.environ.get("IMAP_FOLDER", "INBOX")
interval = int(os.environ.get("IMAP_POLL_SECONDS", "30"))
if not host:
print("[poller] IMAP_HOST not set — email intake disabled (web form still works).")
return
print(f"[poller] Watching {user} on {host} every {interval}s.")
while True:
try:
imap = imaplib.IMAP4_SSL(host)
imap.login(user, password)
imap.select(folder)
_, data = imap.search(None, "UNSEEN")
for num in data[0].split():
_, msg_data = imap.fetch(num, "(RFC822)")
process_message(msg_data[0][1])
imap.store(num, "+FLAGS", "\\Seen")
imap.logout()
except Exception as e:
print(f"[poller] Error: {e}")
time.sleep(interval)
def start():
t = threading.Thread(target=poll_loop, daemon=True)
t.start()
@@ -1,3 +0,0 @@
flask>=3.0
werkzeug>=3.0
gunicorn>=21.0
@@ -1,136 +0,0 @@
/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */
:root {
--paper: #fafbfc;
--card: #ffffff;
--ink: #16232e;
--ink-soft: #5a6b78;
--line: #dfe5ea;
--approve: #0e7c66;
--deny: #b3372f;
--pending: #b7791f;
--focus: #2563a8;
--radius: 8px;
font-size: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55;
}
.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; }
a { color: var(--focus); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* top bar */
.topbar {
display: flex; align-items: center; gap: 2rem; flex-wrap: wrap;
padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2;
}
.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; }
.brand span { color: #7fd6c2; }
.topbar nav { display: flex; gap: 1.2rem; flex: 1; }
.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; }
.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; }
.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; }
.who a { color: #b9c6cf; }
.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; }
h1 { font-size: 1.6rem; margin: 0 0 .3rem; }
h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; }
.sub { color: var(--ink-soft); margin: 0 0 1.6rem; }
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem;
}
/* forms */
label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; }
label small { font-weight: 400; color: var(--ink-soft); }
input[type=text], input[type=password], input[type=email], select, textarea {
width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; background: #fff;
}
textarea { min-height: 130px; resize: vertical; }
textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; }
.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; }
.check input { width: auto; }
button, .btn {
display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem;
font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer;
border: 1px solid var(--ink); background: var(--ink); color: #fff;
}
button:hover, .btn:hover { background: #223546; text-decoration: none; }
.btn.approve { background: var(--approve); border-color: var(--approve); }
.btn.deny { background: var(--deny); border-color: var(--deny); }
.btn.ghost { background: transparent; color: var(--ink); }
.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; }
/* the signature: rubber-stamp status */
.stamp {
display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; border: 2px solid;
border-radius: 4px; transform: rotate(-2deg);
}
.stamp.approved { color: var(--approve); border-color: var(--approve); }
.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); }
.stamp.pending { color: var(--pending); border-color: var(--pending); }
.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; }
.chip {
display: inline-block; font-size: .72rem; padding: .05rem .5rem;
border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft);
}
/* tables */
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); }
td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
tr:hover td { background: #f2f6f8; }
/* ledger (audit log) */
.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; }
.ledger li {
display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem;
padding: .55rem .2rem; border-bottom: 1px dashed var(--line);
}
.ledger .t { color: var(--ink-soft); }
.ledger .e { font-weight: 600; }
@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } }
/* stat row */
.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: .8rem 1rem; }
.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; }
.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; }
.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; }
.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; }
/* login */
.login-box { max-width: 380px; margin: 12vh auto 0; }
.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; }
.login-box .brand-lg span { color: var(--approve); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; }
@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } }
.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
form.inline { display: inline; }
details { margin-top: .4rem; }
summary { cursor: pointer; font-size: .85rem; color: var(--focus); }
.muted { color: var(--ink-soft); font-size: .85rem; }
.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line);
border-radius: 6px; padding: .8rem 1rem; }
@media (prefers-reduced-motion: no-preference) {
.stamp.big { animation: stampIn .25s ease-out; }
@keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; }
to { transform: rotate(-2deg) scale(1); opacity: 1; } }
}
@@ -1,53 +0,0 @@
{% extends "base.html" %}
{% block title %}Workflows &amp; requests — ApprovalFlow{% endblock %}
{% block content %}
<h1>Workflows &amp; requests</h1>
<p class="sub">Create workflows, watch every request, and open any of them for the full audit trail.</p>
<div class="stats">
<div class="stat"><b>{{ stats.get('pending', 0) }}</b><span>Pending</span></div>
<div class="stat"><b>{{ stats.get('approved', 0) }}</b><span>Approved</span></div>
<div class="stat"><b>{{ stats.get('denied', 0) }}</b><span>Denied</span></div>
<div class="stat"><b>{{ stats.get('rejected', 0) }}</b><span>Rejected</span></div>
</div>
<h2>Workflows</h2>
<div class="card">
<table>
<tr><th>Name</th><th>Intake tag</th><th>Allowed domain</th><th>Approver</th><th>Status</th><th></th></tr>
{% for w in workflows %}
<tr>
<td><b>{{ w.name }}</b>{% if w.description %}<br><span class="muted">{{ w.description }}</span>{% endif %}</td>
<td class="mono">+{{ w.slug }} / [{{ w.slug }}]</td>
<td class="mono">@{{ w.allowed_domain }}</td>
<td class="mono">{{ w.approver_email }}</td>
<td>{% if w.active %}<span class="chip">active</span>{% else %}<span class="chip">paused</span>{% endif %}</td>
<td><a class="btn ghost small" href="{{ url_for('workflow_edit', wid=w.id) }}">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No workflows yet — create the first one.</td></tr>
{% endfor %}
</table>
<a class="btn" href="{{ url_for('workflow_edit') }}">New workflow</a>
</div>
<h2>Requests</h2>
<div class="card">
<table>
<tr><th>#</th><th>Workflow</th><th>From</th><th>Subject</th><th>Source</th><th>Status</th><th>Received</th></tr>
{% for r in rows %}
<tr>
<td class="mono"><a href="{{ url_for('admin_request', rid=r.id) }}">{{ r.id }}</a></td>
<td>{{ r.wf_name }}</td>
<td class="mono">{{ r.requester }}</td>
<td><a href="{{ url_for('admin_request', rid=r.id) }}">{{ r.subject }}</a></td>
<td><span class="chip">{{ r.source }}</span></td>
<td><span class="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">No requests yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -1,35 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ApprovalFlow{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if session.get('user_id') %}
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}">Approval<span>Flow</span></a>
<nav>
<a href="{{ url_for('index') }}" {% if request.endpoint=='index' %}class="on"{% endif %}>New request</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin') }}" {% if request.endpoint in ('admin','admin_request','workflow_edit') %}class="on"{% endif %}>Workflows &amp; requests</a>
<a href="{{ url_for('users') }}" {% if request.endpoint=='users' %}class="on"{% endif %}>Users</a>
{% endif %}
</nav>
<div class="who">
<span class="mono">{{ session['username'] }}</span>
<a href="{{ url_for('logout') }}">Sign out</a>
</div>
</header>
{% endif %}
<main class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -1,42 +0,0 @@
{% extends "base.html" %}
{% block title %}Decision — ApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card" style="max-width:560px">
{% if stage == 'notfound' %}
<h1>Link not valid</h1>
<p class="sub">This decision link doesn't match any request. It may have been mistyped or removed.</p>
{% elif stage == 'confirm' %}
{% if req.status != 'pending' %}
<h1>Already decided</h1>
<p class="sub">Request #{{ req.id }} was already
<span class="stamp {{ req.status }}">{{ req.status }}</span>
{% if req.decided_by %} by {{ req.decided_by }}{% endif %}.</p>
{% else %}
<h1>{% if action == 'approve' %}Approve{% else %}Deny{% endif %} this request?</h1>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · request #{{ req.id }}</p>
<p><b>From:</b> {{ req.requester }}<br><b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
<form method="post">
<label for="d">Your email <small>(recorded in the log and used to notify you)</small></label>
<input id="d" type="email" name="decided_by" value="{{ req.approver_email }}">
<button type="submit" class="btn {{ 'approve' if action == 'approve' else 'deny' }}">
{% if action == 'approve' %}Approve request{% else %}Deny request{% endif %}
</button>
</form>
<p class="muted">Everyone involved so far will be emailed the outcome.</p>
{% endif %}
{% elif stage == 'done' %}
{% if status == 'ok' %}
<p style="text-align:center;margin:1.5rem 0">
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</p>
<h1 style="text-align:center">Request #{{ req.id }}</h1>
<p class="sub" style="text-align:center">{{ message }}</p>
{% else %}
<h1>{{ message }}</h1>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -1,46 +0,0 @@
{% extends "base.html" %}
{% block title %}New request — ApprovalFlow{% endblock %}
{% block content %}
<h1>Send a request for approval</h1>
<p class="sub">Pick a workflow, write your message, and it goes straight to that workflow's approver.</p>
{% if workflows %}
<div class="card">
<form method="post">
<label for="wf">Workflow</label>
<select id="wf" name="workflow_id" required>
{% for w in workflows %}
<option value="{{ w.id }}">{{ w.name }}{% if w.description %} — {{ w.description }}{% endif %}</option>
{% endfor %}
</select>
<label for="s">Subject</label>
<input id="s" type="text" name="subject" maxlength="200" required>
<label for="b">Message</label>
<textarea id="b" name="body" required></textarea>
<button type="submit">Send for approval</button>
</form>
</div>
{% else %}
<div class="card">
<p class="muted">You don't have access to any workflow yet. Ask an administrator to grant you access.</p>
</div>
{% endif %}
{% if my_requests %}
<h2>Your recent requests</h2>
<div class="card">
<table>
<tr><th>#</th><th>Workflow</th><th>Subject</th><th>Status</th><th>Sent</th></tr>
{% for r in my_requests %}
<tr>
<td class="mono">{{ r.id }}</td>
<td>{{ r.wf_name }}</td>
<td>{{ r.subject }}</td>
<td><span class="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endblock %}
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block title %}Sign in — ApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card">
<div class="brand-lg">Approval<span>Flow</span></div>
<p class="sub">Sign in to send and track requests.</p>
<form method="post">
<label for="u">Username</label>
<input id="u" type="text" name="username" autofocus required>
<label for="p">Password</label>
<input id="p" type="password" name="password" required>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}
@@ -1,40 +0,0 @@
{% extends "base.html" %}
{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All requests</a></p>
<div class="rowline">
<h1>Request #{{ req.id }}</h1>
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</div>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · via {{ req.source }} · received {{ req.created_at }}</p>
<div class="card">
<p><b>From:</b> <span class="mono">{{ req.requester }}</span><br>
<b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
{% if req.decided_by %}
<p class="muted">Decided by <span class="mono">{{ req.decided_by }}</span> at {{ req.decided_at }}</p>
{% endif %}
{% if req.status == 'pending' %}
<form method="post" class="rowline">
<button class="btn approve small" name="action" value="approve">Approve now</button>
<button class="btn deny small" name="action" value="deny">Deny now</button>
<span class="muted">Deciding here notifies everyone involved, same as the email links.</span>
</form>
{% endif %}
</div>
<h2>People involved so far</h2>
<div class="card">
{% for p in participants %}<span class="chip">{{ p }}</span> {% else %}<span class="muted">None recorded.</span>{% endfor %}
</div>
<h2>Full log</h2>
<div class="card">
<ul class="ledger">
{% for l in logs %}
<li><span class="t">{{ l.at }}</span><span class="e">{{ l.event }}</span><span>{{ l.detail }}</span></li>
{% endfor %}
</ul>
</div>
{% endblock %}
@@ -1,66 +0,0 @@
{% extends "base.html" %}
{% block title %}Users — ApprovalFlow{% endblock %}
{% block content %}
<h1>Users</h1>
<p class="sub">Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.</p>
<div class="card">
<h2 style="margin-top:0">Add a user</h2>
<form method="post">
<input type="hidden" name="action" value="create">
<div class="grid2">
<div><label>Username</label><input type="text" name="username" required></div>
<div><label>Password</label><input type="password" name="password" required></div>
<div><label>Email <small>(used as requester address and for outcome notices)</small></label>
<input type="email" name="email"></div>
</div>
<label class="check"><input type="checkbox" name="is_admin"> Administrator</label>
<button type="submit">Create user</button>
</form>
</div>
{% for u in users %}
<div class="card">
<div class="rowline">
<b>{{ u.username }}</b>
{% if u.is_admin %}<span class="chip">admin</span>{% endif %}
{% if u.email %}<span class="mono muted">{{ u.email }}</span>{% endif %}
<span style="flex:1"></span>
{% if u.id != session['user_id'] %}
<form method="post" class="inline" onsubmit="return confirm('Delete {{ u.username }}?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button class="btn ghost small">Delete</button>
</form>
{% endif %}
</div>
{% if not u.is_admin %}
<form method="post">
<input type="hidden" name="action" value="access">
<input type="hidden" name="user_id" value="{{ u.id }}">
<label>Workflow access</label>
{% for w in workflows %}
<label class="check" style="margin-top:.3rem">
<input type="checkbox" name="workflow_ids" value="{{ w.id }}"
{% if w.id in access.get(u.id, ()) %}checked{% endif %}> {{ w.name }}
</label>
{% else %}
<span class="muted">No workflows exist yet.</span>
{% endfor %}
{% if workflows %}<button class="small" type="submit">Save access</button>{% endif %}
</form>
{% endif %}
<details>
<summary>Reset password</summary>
<form method="post" class="rowline" style="margin-top:.5rem">
<input type="hidden" name="action" value="password">
<input type="hidden" name="user_id" value="{{ u.id }}">
<input type="password" name="password" placeholder="New password" required style="max-width:240px">
<button class="small" type="submit">Set password</button>
</form>
</details>
</div>
{% endfor %}
{% endblock %}
@@ -1,61 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All workflows</a></p>
<h1>{{ 'Edit workflow' if wf else 'New workflow' }}</h1>
<p class="sub">Each workflow has its own allowed sender domain, its own approver, and its own email wording.</p>
<form method="post">
<div class="card">
<h2 style="margin-top:0">Basics</h2>
<div class="grid2">
<div>
<label for="n">Name</label>
<input id="n" type="text" name="name" value="{{ wf.name if wf else '' }}" required>
</div>
<div>
<label for="de">Description <small>(shown to users on the main page)</small></label>
<input id="de" type="text" name="description" value="{{ wf.description if wf else '' }}">
</div>
<div>
<label for="ad">Allowed sender domain <small>(only email from this domain is accepted)</small></label>
<input id="ad" type="text" name="allowed_domain" placeholder="example.com"
value="{{ wf.allowed_domain if wf else '' }}" required>
</div>
<div>
<label for="ap">Approver email</label>
<input id="ap" type="email" name="approver_email" value="{{ wf.approver_email if wf else '' }}" required>
</div>
</div>
<label class="check"><input type="checkbox" name="active" {% if not wf or wf.active %}checked{% endif %}> Active — accept new requests</label>
{% if wf %}<p class="muted">Email intake: send to <span class="mono">inbox+{{ wf.slug }}@your-domain</span> or put <span class="mono">[{{ wf.slug }}]</span> in the subject.</p>{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0">Email wording</h2>
<p class="muted">Placeholders: <span class="mono">{workflow} {requester} {subject} {body} {request_id} {decided_by} {approve_url} {deny_url}</span>. Leave a field empty to use the default.</p>
<label>Approval request — subject</label>
<input type="text" name="approval_subject" value="{{ wf.approval_subject if wf else defaults.approval_subject }}">
<label>Approval request — body <small>(must include {approve_url} and {deny_url})</small></label>
<textarea class="tpl" name="approval_body">{{ wf.approval_body if wf else defaults.approval_body }}</textarea>
<div class="grid2">
<div>
<label>Approved notice — subject</label>
<input type="text" name="approved_subject" value="{{ wf.approved_subject if wf else defaults.approved_subject }}">
<label>Approved notice — body</label>
<textarea class="tpl" name="approved_body">{{ wf.approved_body if wf else defaults.approved_body }}</textarea>
</div>
<div>
<label>Denied notice — subject</label>
<input type="text" name="denied_subject" value="{{ wf.denied_subject if wf else defaults.denied_subject }}">
<label>Denied notice — body</label>
<textarea class="tpl" name="denied_body">{{ wf.denied_body if wf else defaults.denied_body }}</textarea>
</div>
</div>
</div>
<button type="submit">{{ 'Save changes' if wf else 'Create workflow' }}</button>
</form>
{% endblock %}
Binary file not shown.
@@ -1,21 +0,0 @@
# --- Web app ---
SECRET_KEY=change-me-to-a-long-random-string
BASE_URL=http://localhost:8000 # public URL used in approve/deny links
PORT=8000
ADMIN_USERNAME=admin # created on first run only
ADMIN_PASSWORD=changeme
# --- Inbound email (IMAP) — leave IMAP_HOST empty to disable email intake ---
IMAP_HOST=imap.example.com
IMAP_USER=inbox@example.com
IMAP_PASSWORD=secret
IMAP_FOLDER=INBOX
IMAP_POLL_SECONDS=30
# --- Outbound email (SMTP) — leave SMTP_HOST empty to print mail to console (dev) ---
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls # starttls | ssl | none
SMTP_USER=inbox@example.com
SMTP_PASSWORD=secret
SMTP_FROM=approvals@example.com
@@ -1,9 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATABASE_PATH=/data/approvalflow.db
VOLUME /data
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
@@ -1,84 +0,0 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests arrive by email (or through a login-protected web form), get redirected to an approver, and once approved or denied, **everyone involved up to that point** is notified by email. Every request keeps a full audit log of everything that happened to it.
## What it does
- **Email intake.** An IMAP poller watches a mailbox. Incoming messages become requests and enter the workflow's **approval chain** with one-click **Approve** / **Deny** links.
- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted.
- **Decisions notify everyone.** On the final outcome (approved or denied), the requester, anyone CC'd on the original email, and every approver involved **up to that point** receive the outcome email.
- **Full log.** Every request records: received, matched workflow, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp.
- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`.
- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser.
- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows.
## Quick start (Docker)
```bash
cp .env.example .env # edit it: SECRET_KEY, BASE_URL, IMAP_*, SMTP_*
docker compose up -d
```
Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`.
## Quick start (bare Python)
```bash
pip install -r requirements.txt
cp .env.example .env
set -a; source .env; set +a # or export the variables another way
python app.py
```
Leaving `SMTP_HOST` empty prints outgoing mail to the console and leaving `IMAP_HOST` empty disables email intake — handy for trying the app before wiring up a mailbox.
## How email intake targets a workflow
1. **Plus-addressing:** mail sent to `inbox+<workflow-slug>@your-domain` targets that workflow.
2. **Subject tag:** a subject containing `[<workflow-slug>]`.
3. **Fallback:** if exactly one active workflow exists, it is used.
Unmatched mail is ignored.
## The request lifecycle
```
email / web form
│ received + logged
approval request → approver 1 ── deny ──► STOP: DENIED notice to
│ approve everyone involved so far
approval request → approver 2 ── deny ──► STOP: DENIED notice ...
│ approve
⋮ (… every approver in the chain, in order)
last approver approves ──► APPROVED notice to everyone involved
(requester + CCs + all approvers)
```
Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps.
## Personalization placeholders
Usable in every workflow's subject and body templates:
`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{approve_url}`, `{deny_url}`.
## Security notes
- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets.
- Decision links are unguessable random tokens and work only once; a second click shows "already decided."
- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately.
- Non-admin users only ever see and use the workflows they've been granted.
## Project layout
```
app.py Flask routes, auth, admin pages
db.py SQLite schema + audit-log helpers
pipeline.py approval request + decision/notification logic
poller.py IMAP inbox watcher (background thread)
mailer.py SMTP sending + template rendering
templates/ pages static/style.css styling
```
@@ -1,298 +0,0 @@
"""ApprovalFlow — self-hosted email approval workflows."""
import functools
import json
import os
import re
import secrets
from flask import (Flask, abort, flash, g, redirect, render_template,
request, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
import db
import pipeline
import poller
from db import DEFAULT_TEMPLATES
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# ---------------------------------------------------------------- helpers
@app.template_filter("fromjson")
def fromjson_filter(s):
try:
return json.loads(s or "[]")
except Exception:
return []
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return s or "workflow"
def login_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return view(*a, **kw)
return wrapped
def admin_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
if not session.get("is_admin"):
abort(403)
return view(*a, **kw)
return wrapped
@app.before_request
def open_db():
g.db = db.get_db()
@app.teardown_request
def close_db(exc):
d = g.pop("db", None)
if d:
d.close()
def user_workflows(user_id):
if session.get("is_admin"):
return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall()
return g.db.execute(
"SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id "
"WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall()
# ---------------------------------------------------------------- auth
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = g.db.execute("SELECT * FROM users WHERE username=?",
(request.form.get("username", "").strip(),)).fetchone()
if user and check_password_hash(user["password_hash"], request.form.get("password", "")):
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
return redirect(request.args.get("next") or url_for("index"))
flash("Wrong username or password.", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- main page
@app.route("/", methods=["GET", "POST"])
@login_required
def index():
workflows = user_workflows(session["user_id"])
if request.method == "POST":
wf_id = request.form.get("workflow_id", type=int)
wf = next((w for w in workflows if w["id"] == wf_id), None)
if wf is None:
abort(403) # not allowed to use this workflow
subject = request.form.get("subject", "").strip()
body = request.form.get("body", "").strip()
if not subject or not body:
flash("Subject and message are both required.", "error")
else:
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
requester = user["email"] or user["username"]
rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body)
db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}")
g.db.commit()
pipeline.send_approval_request(rid)
flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok")
return redirect(url_for("index"))
my_requests = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15",
(session["username"],
(g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"),
)).fetchall()
return render_template("index.html", workflows=workflows, my_requests=my_requests)
# ---------------------------------------------------------------- decision links (from email)
@app.route("/decision/<token>/<action>", methods=["GET", "POST"])
def decision(token, action):
if action not in ("approve", "deny"):
abort(404)
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone()
if req is None:
return render_template("decision.html", stage="notfound"), 404
chain = pipeline.approver_chain(req)
approver = chain[min(req["current_step"], len(chain) - 1)] if chain else ""
if request.method == "POST":
decided_by = request.form.get("decided_by", "").strip() or approver
status, req2, message = pipeline.decide(token, action, decided_by)
return render_template("decision.html", stage="done", req=req2, message=message,
action=action, status=status)
return render_template("decision.html", stage="confirm", req=req, action=action,
approver=approver, chain=chain)
# ---------------------------------------------------------------- admin: dashboard & requests
@app.route("/admin")
@admin_required
def admin():
rows = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"ORDER BY r.id DESC LIMIT 200").fetchall()
stats = g.db.execute(
"SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
return render_template("admin.html", rows=rows, workflows=workflows,
stats={s["status"]: s["c"] for s in stats})
@app.route("/admin/request/<int:rid>", methods=["GET", "POST"])
@admin_required
def admin_request(rid):
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
if req is None:
abort(404)
if request.method == "POST" and req["status"] == "pending":
action = request.form.get("action")
if action in ("approve", "deny"):
pipeline.decide(req["token"], action, f"{session['username']} (admin)")
return redirect(url_for("admin_request", rid=rid))
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall()
participants = json.loads(req["participants"])
chain = pipeline.approver_chain(req)
return render_template("request_detail.html", req=req, logs=logs,
participants=participants, chain=chain)
# ---------------------------------------------------------------- admin: workflows
WF_FIELDS = ("approval_subject", "approval_body", "approved_subject",
"approved_body", "denied_subject", "denied_body")
@app.route("/admin/workflows/new", methods=["GET", "POST"])
@app.route("/admin/workflows/<int:wid>", methods=["GET", "POST"])
@admin_required
def workflow_edit(wid=None):
wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None
if wid and wf is None:
abort(404)
if request.method == "POST":
f = request.form
name = f.get("name", "").strip()
approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()]
if not name or not approvers:
flash("A name and at least one approver are required.", "error")
else:
templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS}
try:
if wf is None:
g.db.execute(
"INSERT INTO workflows (name, slug, description, approvers, active,"
" approval_subject, approval_body, approved_subject, approved_body,"
" denied_subject, denied_body, created_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(name, slugify(name), f.get("description", "").strip(), json.dumps(approvers),
1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], db.now()))
flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok")
else:
g.db.execute(
"UPDATE workflows SET name=?, slug=?, description=?, approvers=?,"
" active=?, approval_subject=?, approval_body=?,"
" approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?",
(name, slugify(name), f.get("description", "").strip(), json.dumps(approvers),
1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], wid))
flash(f"Workflow “{name}” saved.", "ok")
g.db.commit()
return redirect(url_for("admin"))
except Exception as e:
flash(f"Could not save: {e}", "error")
return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES)
# ---------------------------------------------------------------- admin: users & access
@app.route("/admin/users", methods=["GET", "POST"])
@admin_required
def users():
if request.method == "POST":
f = request.form
act = f.get("action")
try:
if act == "create":
if not f.get("username") or not f.get("password"):
flash("Username and password are required.", "error")
else:
g.db.execute(
"INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)",
(f["username"].strip(), generate_password_hash(f["password"]),
f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now()))
flash(f"User “{f['username']}” created.", "ok")
elif act == "delete":
uid = f.get("user_id", type=int)
if uid == session["user_id"]:
flash("You can't delete your own account.", "error")
else:
g.db.execute("DELETE FROM users WHERE id=?", (uid,))
flash("User deleted.", "ok")
elif act == "password":
g.db.execute("UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(f["password"]), f.get("user_id", type=int)))
flash("Password updated.", "ok")
elif act == "access":
uid = f.get("user_id", type=int)
g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,))
for wid in request.form.getlist("workflow_ids", type=int):
g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)",
(uid, wid))
flash("Access updated.", "ok")
g.db.commit()
except Exception as e:
flash(f"Error: {e}", "error")
return redirect(url_for("users"))
all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
access = {}
for row in g.db.execute("SELECT * FROM user_workflows"):
access.setdefault(row["user_id"], set()).add(row["workflow_id"])
return render_template("users.html", users=all_users, workflows=workflows, access=access)
# ---------------------------------------------------------------- boot
db.init_db()
poller.start()
if __name__ == "__main__":
app.run(host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "8000")),
debug=os.environ.get("FLASK_DEBUG") == "1")
@@ -1,176 +0,0 @@
"""SQLite data layer for ApprovalFlow."""
import json
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from werkzeug.security import generate_password_hash
DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order
active INTEGER NOT NULL DEFAULT 1,
-- personalization (templates support {placeholders})
approval_subject TEXT NOT NULL,
approval_body TEXT NOT NULL,
approved_subject TEXT NOT NULL,
approved_body TEXT NOT NULL,
denied_subject TEXT NOT NULL,
denied_body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_workflows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, workflow_id)
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL REFERENCES workflows(id),
source TEXT NOT NULL, -- 'email' or 'web'
requester TEXT NOT NULL, -- email address (or username for web)
subject TEXT NOT NULL,
body TEXT NOT NULL,
participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far
status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied
current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain
token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links
decided_by TEXT DEFAULT '',
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE,
at TEXT NOT NULL,
event TEXT NOT NULL,
detail TEXT DEFAULT ''
);
"""
DEFAULT_TEMPLATES = {
"approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}",
"approval_body": (
"A request needs your decision (approval step {step} of {total_steps}).\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Approve: {approve_url}\n"
"Deny: {deny_url}\n\n"
"If you approve, the request moves on to the next approver.\n"
"If you deny, the workflow stops and everyone involved is notified.\n"
),
"approved_subject": "[{workflow}] Approved: {subject}",
"approved_body": (
"The following request was APPROVED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
"denied_subject": "[{workflow}] Denied: {subject}",
"denied_body": (
"The following request was DENIED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
}
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
return con
def _migrate(con):
"""Upgrade a database created by the single-approver version, if present."""
cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")}
if "approver_email" in cols and "approvers" not in cols:
con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'")
for w in con.execute("SELECT id, approver_email FROM workflows").fetchall():
con.execute("UPDATE workflows SET approvers=? WHERE id=?",
(json.dumps([w["approver_email"]]), w["id"]))
print("[migrate] Converted single approver_email to approver chains.")
rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")}
if rcols and "current_step" not in rcols:
con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0")
print("[migrate] Added current_step to requests.")
con.commit()
def init_db():
con = get_db()
_migrate(con)
con.executescript(SCHEMA)
# bootstrap admin user on first run
if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0:
username = os.environ.get("ADMIN_USERNAME", "admin")
password = os.environ.get("ADMIN_PASSWORD", "changeme")
con.execute(
"INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)",
(username, generate_password_hash(password), now()),
)
print(f"[init] Created admin user '{username}'. Change the password immediately.")
con.commit()
con.close()
def log(con, request_id, event, detail=""):
con.execute(
"INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)",
(request_id, now(), event, detail),
)
def add_participants(con, request_id, emails):
"""Add people to the 'involved so far' list of a request."""
row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone()
current = json.loads(row["participants"])
for e in emails:
e = (e or "").strip().lower()
if e and e not in current:
current.append(e)
con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id))
return current
def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()):
token = secrets.token_urlsafe(32)
cur = con.execute(
"INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(workflow_id, source, requester, subject, body, token, now()),
)
rid = cur.lastrowid
add_participants(con, rid, [requester, *extra_participants])
log(con, rid, "received", f"Request received via {source} from {requester}")
return rid, token
@@ -1,9 +0,0 @@
services:
approvalflow:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
restart: unless-stopped
@@ -1,54 +0,0 @@
"""Outbound email (SMTP) and template rendering."""
import os
import smtplib
from email.message import EmailMessage
def render(template: str, **vars) -> str:
"""Safe {placeholder} substitution — unknown placeholders are left as-is."""
out = template
for k, v in vars.items():
out = out.replace("{" + k + "}", str(v))
return out
def send_mail(to_addrs, subject, body):
"""Send a plain-text email. Returns (ok, detail)."""
if isinstance(to_addrs, str):
to_addrs = [to_addrs]
to_addrs = [a for a in to_addrs if a]
if not to_addrs:
return False, "no recipients"
host = os.environ.get("SMTP_HOST", "")
port = int(os.environ.get("SMTP_PORT", "587"))
user = os.environ.get("SMTP_USER", "")
password = os.environ.get("SMTP_PASSWORD", "")
sender = os.environ.get("SMTP_FROM", user or "approvalflow@localhost")
security = os.environ.get("SMTP_SECURITY", "starttls").lower() # starttls / ssl / none
msg = EmailMessage()
msg["From"] = sender
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not host:
# Dev mode: print instead of sending so the app is testable without SMTP.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
return True, "dev mode (SMTP_HOST not set) — printed to console"
try:
if security == "ssl":
server = smtplib.SMTP_SSL(host, port, timeout=30)
else:
server = smtplib.SMTP(host, port, timeout=30)
if security == "starttls":
server.starttls()
if user:
server.login(user, password)
server.send_message(msg)
server.quit()
return True, f"sent to {', '.join(to_addrs)}"
except Exception as e:
return False, f"SMTP error: {e}"
@@ -1,153 +0,0 @@
"""Request pipeline shared by the web form and the email poller.
Each workflow has an ordered chain of approvers. A request moves through the
chain one approver at a time:
- DENY at any step -> the workflow STOPS; everyone involved so far gets
the denied notice.
- APPROVE -> the request moves to the next approver in the chain.
- APPROVE (last) -> the request is fully approved; everyone involved so
far gets the approved notice.
Each step gets a fresh secret token, so an earlier approver's links cannot
decide later steps.
"""
import json
import os
import secrets
import db
from mailer import render, send_mail
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
def approver_chain(wf):
try:
chain = json.loads(wf["approvers"])
except Exception:
chain = []
return [a.strip() for a in chain if a and a.strip()]
def _vars(wf, req, **extra):
chain = approver_chain(wf)
v = {
"workflow": wf["name"],
"requester": req["requester"],
"subject": req["subject"],
"body": req["body"],
"request_id": req["id"],
"step": min(req["current_step"], max(len(chain) - 1, 0)) + 1,
"total_steps": len(chain),
"approve_url": f"{BASE_URL}/decision/{req['token']}/approve",
"deny_url": f"{BASE_URL}/decision/{req['token']}/deny",
}
v.update(extra)
return v
def current_approver(wf, req):
chain = approver_chain(wf)
if not chain:
return None
return chain[min(req["current_step"], len(chain) - 1)]
def send_approval_request(request_id):
"""Send the approval request to the approver of the CURRENT step."""
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
chain = approver_chain(wf)
approver = current_approver(wf, req)
if approver is None:
db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured")
con.commit()
return False
v = _vars(wf, req)
ok, detail = send_mail(approver,
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v))
db.add_participants(con, request_id, [approver])
db.log(con, request_id,
"approval requested" if ok else "mail error",
f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver}{detail}")
con.commit()
return ok
finally:
con.close()
def _notify_outcome(con, wf, req, new_status, decided_by):
participants = db.add_participants(con, req["id"], [decided_by])
v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper())
subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"]
body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"]
ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v))
db.log(con, req["id"],
"participants notified" if ok else "mail error",
f"{new_status.capitalize()} notice to {', '.join(participants)}{detail}")
def decide(token, action, decided_by):
"""Handle a decision at the request's current step.
Returns (status, request_row_or_None, message).
"""
assert action in ("approve", "deny")
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone()
if req is None:
return "notfound", None, "This link is not valid — it may belong to a step that was already decided."
if req["status"] != "pending":
return "already", req, f"This request was already {req['status']}."
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
chain = approver_chain(wf)
step = req["current_step"]
if action == "deny":
# STOP the workflow, notify everyone involved so far.
con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=? WHERE id=?",
(decided_by, db.now(), req["id"]))
db.log(con, req["id"], "denied",
f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped")
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
_notify_outcome(con, wf, req, "denied", decided_by)
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. "
"The workflow has stopped and everyone involved has been notified.")
# approve
db.log(con, req["id"], "step approved",
f"Step {step + 1}/{len(chain)} approved by {decided_by}")
db.add_participants(con, req["id"], [decided_by])
if step + 1 >= len(chain):
# Last approver — fully approved.
con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?",
(decided_by, db.now(), req["id"]))
db.log(con, req["id"], "approved",
f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}")
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
_notify_outcome(con, wf, req, "approved", decided_by)
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). "
"Everyone involved has been notified.")
# Move to the next approver with a fresh token (old links stop working).
new_token = secrets.token_urlsafe(32)
con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?",
(step + 1, new_token, req["id"]))
con.commit()
send_approval_request(req["id"])
req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the "
f"next approver ({chain[step + 1]}).")
finally:
con.close()
@@ -1,132 +0,0 @@
"""IMAP inbox poller.
Watches a mailbox for new messages and turns them into approval requests.
Workflow matching, in order:
1. Plus-addressing: mail sent to inbox+<workflow-slug>@domain targets that workflow.
2. Subject tag: a subject containing [<workflow-slug>] targets that workflow.
3. Fallback: if exactly one active workflow exists, it is used.
Unmatched mail is ignored.
"""
import email
import email.utils
import imaplib
import os
import re
import threading
import time
import traceback
import db
import pipeline
def _addr_list(msg, header):
raw = msg.get_all(header, [])
return [a.lower() for _, a in email.utils.getaddresses(raw) if a]
def _body_text(msg):
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and not part.get("Content-Disposition"):
try:
return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "replace")
except Exception:
continue
return "(no readable text body)"
payload = msg.get_payload(decode=True)
if payload is None:
return str(msg.get_payload())
return payload.decode(msg.get_content_charset() or "utf-8", "replace")
def _match_workflow(con, recipients, subject):
workflows = con.execute("SELECT * FROM workflows WHERE active=1").fetchall()
by_slug = {w["slug"]: w for w in workflows}
# 1. plus-addressing: inbox+slug@...
for addr in recipients:
m = re.match(r"^[^+@]+\+([a-z0-9\-]+)@", addr)
if m and m.group(1) in by_slug:
return by_slug[m.group(1)], "plus-address"
# 2. subject tag [slug]
for slug, w in by_slug.items():
if f"[{slug}]" in subject.lower():
return w, "subject tag"
# 3. single active workflow fallback
if len(workflows) == 1:
return workflows[0], "only active workflow"
return None, None
def process_message(raw_bytes):
msg = email.message_from_bytes(raw_bytes)
sender = (email.utils.parseaddr(msg.get("From", ""))[1] or "").lower()
recipients = _addr_list(msg, "To") + _addr_list(msg, "Cc")
subject = msg.get("Subject", "(no subject)")
body = _body_text(msg).strip()
con = db.get_db()
try:
wf, how = _match_workflow(con, recipients, subject)
if wf is None:
print(f"[poller] No workflow matched mail from {sender} — ignored.")
return
# CC'd people are "involved up to this point" too — everyone on To/Cc
# except the sender and the intake mailbox itself (incl. its +tag variants)
inbox_local = (os.environ.get("IMAP_USER") or "").split("@")[0].lower()
def is_intake(addr):
base = addr.split("@")[0].split("+")[0]
return (inbox_local and base == inbox_local) or \
addr.split("@")[0].endswith("+" + wf["slug"])
ccs = [a for a in recipients if a != sender and not is_intake(a)]
rid, token = db.create_request(con, wf["id"], "email", sender, subject, body, extra_participants=ccs)
db.log(con, rid, "matched", f"Matched workflow '{wf['name']}' via {how}")
con.commit()
pipeline.send_approval_request(rid)
print(f"[poller] Request #{rid} created for workflow '{wf['name']}' from {sender}.")
except Exception:
traceback.print_exc()
finally:
con.close()
def poll_loop():
host = os.environ.get("IMAP_HOST", "")
user = os.environ.get("IMAP_USER", "")
password = os.environ.get("IMAP_PASSWORD", "")
folder = os.environ.get("IMAP_FOLDER", "INBOX")
interval = int(os.environ.get("IMAP_POLL_SECONDS", "30"))
if not host:
print("[poller] IMAP_HOST not set — email intake disabled (web form still works).")
return
print(f"[poller] Watching {user} on {host} every {interval}s.")
while True:
try:
imap = imaplib.IMAP4_SSL(host)
imap.login(user, password)
imap.select(folder)
_, data = imap.search(None, "UNSEEN")
for num in data[0].split():
_, msg_data = imap.fetch(num, "(RFC822)")
process_message(msg_data[0][1])
imap.store(num, "+FLAGS", "\\Seen")
imap.logout()
except Exception as e:
print(f"[poller] Error: {e}")
time.sleep(interval)
def start():
t = threading.Thread(target=poll_loop, daemon=True)
t.start()
@@ -1,3 +0,0 @@
flask>=3.0
werkzeug>=3.0
gunicorn>=21.0
@@ -1,136 +0,0 @@
/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */
:root {
--paper: #fafbfc;
--card: #ffffff;
--ink: #16232e;
--ink-soft: #5a6b78;
--line: #dfe5ea;
--approve: #0e7c66;
--deny: #b3372f;
--pending: #b7791f;
--focus: #2563a8;
--radius: 8px;
font-size: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55;
}
.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; }
a { color: var(--focus); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* top bar */
.topbar {
display: flex; align-items: center; gap: 2rem; flex-wrap: wrap;
padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2;
}
.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; }
.brand span { color: #7fd6c2; }
.topbar nav { display: flex; gap: 1.2rem; flex: 1; }
.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; }
.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; }
.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; }
.who a { color: #b9c6cf; }
.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; }
h1 { font-size: 1.6rem; margin: 0 0 .3rem; }
h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; }
.sub { color: var(--ink-soft); margin: 0 0 1.6rem; }
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem;
}
/* forms */
label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; }
label small { font-weight: 400; color: var(--ink-soft); }
input[type=text], input[type=password], input[type=email], select, textarea {
width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; background: #fff;
}
textarea { min-height: 130px; resize: vertical; }
textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; }
.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; }
.check input { width: auto; }
button, .btn {
display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem;
font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer;
border: 1px solid var(--ink); background: var(--ink); color: #fff;
}
button:hover, .btn:hover { background: #223546; text-decoration: none; }
.btn.approve { background: var(--approve); border-color: var(--approve); }
.btn.deny { background: var(--deny); border-color: var(--deny); }
.btn.ghost { background: transparent; color: var(--ink); }
.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; }
/* the signature: rubber-stamp status */
.stamp {
display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; border: 2px solid;
border-radius: 4px; transform: rotate(-2deg);
}
.stamp.approved { color: var(--approve); border-color: var(--approve); }
.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); }
.stamp.pending { color: var(--pending); border-color: var(--pending); }
.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; }
.chip {
display: inline-block; font-size: .72rem; padding: .05rem .5rem;
border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft);
}
/* tables */
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); }
td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
tr:hover td { background: #f2f6f8; }
/* ledger (audit log) */
.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; }
.ledger li {
display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem;
padding: .55rem .2rem; border-bottom: 1px dashed var(--line);
}
.ledger .t { color: var(--ink-soft); }
.ledger .e { font-weight: 600; }
@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } }
/* stat row */
.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: .8rem 1rem; }
.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; }
.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; }
.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; }
.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; }
/* login */
.login-box { max-width: 380px; margin: 12vh auto 0; }
.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; }
.login-box .brand-lg span { color: var(--approve); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; }
@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } }
.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
form.inline { display: inline; }
details { margin-top: .4rem; }
summary { cursor: pointer; font-size: .85rem; color: var(--focus); }
.muted { color: var(--ink-soft); font-size: .85rem; }
.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line);
border-radius: 6px; padding: .8rem 1rem; }
@media (prefers-reduced-motion: no-preference) {
.stamp.big { animation: stampIn .25s ease-out; }
@keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; }
to { transform: rotate(-2deg) scale(1); opacity: 1; } }
}
@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% block title %}Workflows &amp; requests — ApprovalFlow{% endblock %}
{% block content %}
<h1>Workflows &amp; requests</h1>
<p class="sub">Create workflows, watch every request, and open any of them for the full audit trail.</p>
<div class="stats">
<div class="stat"><b>{{ stats.get('pending', 0) }}</b><span>Pending</span></div>
<div class="stat"><b>{{ stats.get('approved', 0) }}</b><span>Approved</span></div>
<div class="stat"><b>{{ stats.get('denied', 0) }}</b><span>Denied</span></div>
<div class="stat"><b>{{ stats.get('rejected', 0) }}</b><span>Rejected</span></div>
</div>
<h2>Workflows</h2>
<div class="card">
<table>
<tr><th>Name</th><th>Intake tag</th><th>Approval chain</th><th>Status</th><th></th></tr>
{% for w in workflows %}
<tr>
<td><b>{{ w.name }}</b>{% if w.description %}<br><span class="muted">{{ w.description }}</span>{% endif %}</td>
<td class="mono">+{{ w.slug }} / [{{ w.slug }}]</td>
<td class="mono">{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}<br>{% endif %}{% endfor %}</td>
<td>{% if w.active %}<span class="chip">active</span>{% else %}<span class="chip">paused</span>{% endif %}</td>
<td><a class="btn ghost small" href="{{ url_for('workflow_edit', wid=w.id) }}">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No workflows yet — create the first one.</td></tr>
{% endfor %}
</table>
<a class="btn" href="{{ url_for('workflow_edit') }}">New workflow</a>
</div>
<h2>Requests</h2>
<div class="card">
<table>
<tr><th>#</th><th>Workflow</th><th>From</th><th>Subject</th><th>Source</th><th>Status</th><th>Received</th></tr>
{% for r in rows %}
<tr>
<td class="mono"><a href="{{ url_for('admin_request', rid=r.id) }}">{{ r.id }}</a></td>
<td>{{ r.wf_name }}</td>
<td class="mono">{{ r.requester }}</td>
<td><a href="{{ url_for('admin_request', rid=r.id) }}">{{ r.subject }}</a></td>
<td><span class="chip">{{ r.source }}</span></td>
<td><span class="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">No requests yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -1,35 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ApprovalFlow{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if session.get('user_id') %}
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}">Approval<span>Flow</span></a>
<nav>
<a href="{{ url_for('index') }}" {% if request.endpoint=='index' %}class="on"{% endif %}>New request</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin') }}" {% if request.endpoint in ('admin','admin_request','workflow_edit') %}class="on"{% endif %}>Workflows &amp; requests</a>
<a href="{{ url_for('users') }}" {% if request.endpoint=='users' %}class="on"{% endif %}>Users</a>
{% endif %}
</nav>
<div class="who">
<span class="mono">{{ session['username'] }}</span>
<a href="{{ url_for('logout') }}">Sign out</a>
</div>
</header>
{% endif %}
<main class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block title %}Decision — ApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card" style="max-width:560px">
{% if stage == 'notfound' %}
<h1>Link not valid</h1>
<p class="sub">This decision link doesn't match any request. It may have been mistyped or removed.</p>
{% elif stage == 'confirm' %}
{% if req.status != 'pending' %}
<h1>Already decided</h1>
<p class="sub">Request #{{ req.id }} was already
<span class="stamp {{ req.status }}">{{ req.status }}</span>
{% if req.decided_by %} by {{ req.decided_by }}{% endif %}.</p>
{% else %}
<h1>{% if action == 'approve' %}Approve{% else %}Deny{% endif %} this request?</h1>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · request #{{ req.id }} ·
approval step <b>{{ req.current_step + 1 }} of {{ chain | length }}</b></p>
<p><b>From:</b> {{ req.requester }}<br><b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
<form method="post">
<label for="d">Your email <small>(recorded in the log and used to notify you)</small></label>
<input id="d" type="email" name="decided_by" value="{{ approver }}">
<button type="submit" class="btn {{ 'approve' if action == 'approve' else 'deny' }}">
{% if action == 'approve' %}Approve request{% else %}Deny request{% endif %}
</button>
</form>
{% if action == 'approve' %}
{% if req.current_step + 1 < chain | length %}
<p class="muted">Approving sends the request to the next approver: <span class="mono">{{ chain[req.current_step + 1] }}</span>.</p>
{% else %}
<p class="muted">You are the last approver — approving completes the workflow and notifies everyone involved.</p>
{% endif %}
{% else %}
<p class="muted">Denying stops the workflow immediately and notifies everyone involved so far.</p>
{% endif %}
{% endif %}
{% elif stage == 'done' %}
{% if status == 'ok' %}
<p style="text-align:center;margin:1.5rem 0">
{% if req.status == 'pending' %}
<span class="stamp big approved">step approved</span>
{% else %}
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
{% endif %}
</p>
<h1 style="text-align:center">Request #{{ req.id }}</h1>
<p class="sub" style="text-align:center">{{ message }}</p>
{% else %}
<h1>{{ message }}</h1>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -1,46 +0,0 @@
{% extends "base.html" %}
{% block title %}New request — ApprovalFlow{% endblock %}
{% block content %}
<h1>Send a request for approval</h1>
<p class="sub">Pick a workflow, write your message, and it goes straight to that workflow's approver.</p>
{% if workflows %}
<div class="card">
<form method="post">
<label for="wf">Workflow</label>
<select id="wf" name="workflow_id" required>
{% for w in workflows %}
<option value="{{ w.id }}">{{ w.name }}{% if w.description %} — {{ w.description }}{% endif %}</option>
{% endfor %}
</select>
<label for="s">Subject</label>
<input id="s" type="text" name="subject" maxlength="200" required>
<label for="b">Message</label>
<textarea id="b" name="body" required></textarea>
<button type="submit">Send for approval</button>
</form>
</div>
{% else %}
<div class="card">
<p class="muted">You don't have access to any workflow yet. Ask an administrator to grant you access.</p>
</div>
{% endif %}
{% if my_requests %}
<h2>Your recent requests</h2>
<div class="card">
<table>
<tr><th>#</th><th>Workflow</th><th>Subject</th><th>Status</th><th>Sent</th></tr>
{% for r in my_requests %}
<tr>
<td class="mono">{{ r.id }}</td>
<td>{{ r.wf_name }}</td>
<td>{{ r.subject }}</td>
<td><span class="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endblock %}
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block title %}Sign in — ApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card">
<div class="brand-lg">Approval<span>Flow</span></div>
<p class="sub">Sign in to send and track requests.</p>
<form method="post">
<label for="u">Username</label>
<input id="u" type="text" name="username" autofocus required>
<label for="p">Password</label>
<input id="p" type="password" name="password" required>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}
@@ -1,59 +0,0 @@
{% extends "base.html" %}
{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All requests</a></p>
<div class="rowline">
<h1>Request #{{ req.id }}</h1>
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</div>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · via {{ req.source }} · received {{ req.created_at }}</p>
<div class="card">
<p><b>From:</b> <span class="mono">{{ req.requester }}</span><br>
<b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
{% if req.decided_by %}
<p class="muted">Final decision by <span class="mono">{{ req.decided_by }}</span> at {{ req.decided_at }}</p>
{% endif %}
{% if req.status == 'pending' %}
<form method="post" class="rowline">
<button class="btn approve small" name="action" value="approve">Approve this step</button>
<button class="btn deny small" name="action" value="deny">Deny &amp; stop workflow</button>
<span class="muted">Same effect as the current approver's email links.</span>
</form>
{% endif %}
</div>
<h2>Approval chain</h2>
<div class="card">
<ul class="ledger">
{% for a in chain %}
<li>
<span class="t mono">step {{ loop.index }} / {{ chain | length }}</span>
<span class="e mono">{{ a }}</span>
<span>
{% if req.status == 'denied' and loop.index0 == req.current_step %}<span class="stamp denied">denied here</span>
{% elif loop.index0 < req.current_step or req.status == 'approved' %}<span class="stamp approved">approved</span>
{% elif loop.index0 == req.current_step and req.status == 'pending' %}<span class="stamp pending">waiting</span>
{% elif req.status == 'denied' %}<span class="muted">never reached — workflow stopped</span>
{% else %}<span class="muted">not yet reached</span>{% endif %}
</span>
</li>
{% endfor %}
</ul>
</div>
<h2>People involved so far</h2>
<div class="card">
{% for p in participants %}<span class="chip">{{ p }}</span> {% else %}<span class="muted">None recorded.</span>{% endfor %}
</div>
<h2>Full log</h2>
<div class="card">
<ul class="ledger">
{% for l in logs %}
<li><span class="t">{{ l.at }}</span><span class="e">{{ l.event }}</span><span>{{ l.detail }}</span></li>
{% endfor %}
</ul>
</div>
{% endblock %}
@@ -1,66 +0,0 @@
{% extends "base.html" %}
{% block title %}Users — ApprovalFlow{% endblock %}
{% block content %}
<h1>Users</h1>
<p class="sub">Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.</p>
<div class="card">
<h2 style="margin-top:0">Add a user</h2>
<form method="post">
<input type="hidden" name="action" value="create">
<div class="grid2">
<div><label>Username</label><input type="text" name="username" required></div>
<div><label>Password</label><input type="password" name="password" required></div>
<div><label>Email <small>(used as requester address and for outcome notices)</small></label>
<input type="email" name="email"></div>
</div>
<label class="check"><input type="checkbox" name="is_admin"> Administrator</label>
<button type="submit">Create user</button>
</form>
</div>
{% for u in users %}
<div class="card">
<div class="rowline">
<b>{{ u.username }}</b>
{% if u.is_admin %}<span class="chip">admin</span>{% endif %}
{% if u.email %}<span class="mono muted">{{ u.email }}</span>{% endif %}
<span style="flex:1"></span>
{% if u.id != session['user_id'] %}
<form method="post" class="inline" onsubmit="return confirm('Delete {{ u.username }}?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button class="btn ghost small">Delete</button>
</form>
{% endif %}
</div>
{% if not u.is_admin %}
<form method="post">
<input type="hidden" name="action" value="access">
<input type="hidden" name="user_id" value="{{ u.id }}">
<label>Workflow access</label>
{% for w in workflows %}
<label class="check" style="margin-top:.3rem">
<input type="checkbox" name="workflow_ids" value="{{ w.id }}"
{% if w.id in access.get(u.id, ()) %}checked{% endif %}> {{ w.name }}
</label>
{% else %}
<span class="muted">No workflows exist yet.</span>
{% endfor %}
{% if workflows %}<button class="small" type="submit">Save access</button>{% endif %}
</form>
{% endif %}
<details>
<summary>Reset password</summary>
<form method="post" class="rowline" style="margin-top:.5rem">
<input type="hidden" name="action" value="password">
<input type="hidden" name="user_id" value="{{ u.id }}">
<input type="password" name="password" placeholder="New password" required style="max-width:240px">
<button class="small" type="submit">Set password</button>
</form>
</details>
</div>
{% endfor %}
{% endblock %}
@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All workflows</a></p>
<h1>{{ 'Edit workflow' if wf else 'New workflow' }}</h1>
<p class="sub">Each workflow has its own allowed sender domain, its own approver, and its own email wording.</p>
<form method="post">
<div class="card">
<h2 style="margin-top:0">Basics</h2>
<div class="grid2">
<div>
<label for="n">Name</label>
<input id="n" type="text" name="name" value="{{ wf.name if wf else '' }}" required>
</div>
<div>
<label for="de">Description <small>(shown to users on the main page)</small></label>
<input id="de" type="text" name="description" value="{{ wf.description if wf else '' }}">
</div>
</div>
<label for="ap">Approval chain <small>(one email per line, in order — the request goes to the first person; each approval sends it to the next; any denial stops the workflow)</small></label>
<textarea id="ap" name="approvers" class="tpl" required
placeholder="team-lead@example.com&#10;manager@example.com&#10;director@example.com">{% if wf %}{{ '\n'.join(wf.approvers | fromjson) }}{% endif %}</textarea>
<label class="check"><input type="checkbox" name="active" {% if not wf or wf.active %}checked{% endif %}> Active — accept new requests</label>
{% if wf %}<p class="muted">Email intake: send to <span class="mono">inbox+{{ wf.slug }}@your-domain</span> or put <span class="mono">[{{ wf.slug }}]</span> in the subject.</p>{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0">Email wording</h2>
<p class="muted">Placeholders: <span class="mono">{workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {approve_url} {deny_url}</span>. Leave a field empty to use the default.</p>
<label>Approval request — subject</label>
<input type="text" name="approval_subject" value="{{ wf.approval_subject if wf else defaults.approval_subject }}">
<label>Approval request — body <small>(must include {approve_url} and {deny_url})</small></label>
<textarea class="tpl" name="approval_body">{{ wf.approval_body if wf else defaults.approval_body }}</textarea>
<div class="grid2">
<div>
<label>Approved notice — subject</label>
<input type="text" name="approved_subject" value="{{ wf.approved_subject if wf else defaults.approved_subject }}">
<label>Approved notice — body</label>
<textarea class="tpl" name="approved_body">{{ wf.approved_body if wf else defaults.approved_body }}</textarea>
</div>
<div>
<label>Denied notice — subject</label>
<input type="text" name="denied_subject" value="{{ wf.denied_subject if wf else defaults.denied_subject }}">
<label>Denied notice — body</label>
<textarea class="tpl" name="denied_body">{{ wf.denied_body if wf else defaults.denied_body }}</textarea>
</div>
</div>
</div>
<button type="submit">{{ 'Save changes' if wf else 'Create workflow' }}</button>
</form>
{% endblock %}
Binary file not shown.
@@ -1,13 +0,0 @@
# --- Web app ---
SECRET_KEY=change-me-to-a-long-random-string
BASE_URL=http://localhost:8000 # public URL used in approve/deny links
PORT=8000
ADMIN_USERNAME=admin # created on first run only
ADMIN_PASSWORD=changeme
# --- Purelymail account (only login and password are needed) ---
# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS.
# If Two Factor Authentication is enabled on the account, use an App Password.
# Leave MAIL_USER empty to print outgoing mail to the console (dev mode).
MAIL_USER=you@yourdomain.com
MAIL_PASSWORD=secret
@@ -1,9 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATABASE_PATH=/data/approvalflow.db
VOLUME /data
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
@@ -1,86 +0,0 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it.
Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password.
## What it does
- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted.
- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email.
- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp.
- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`.
- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser.
- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows.
## Quick start (Docker)
```bash
cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD
docker compose up -d
```
Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`.
## Quick start (bare Python)
```bash
pip install -r requirements.txt
cp .env.example .env
set -a; source .env; set +a # or export the variables another way
python app.py
```
Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account.
## Mail server
The app is preconfigured for Purelymail's servers — you never enter host or port settings:
| | |
|---|---|
| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS |
Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password.
## The request lifecycle
```
web form (login required)
│ received + logged
approval request → approver 1 ── deny ──► STOP: DENIED notice to
│ approve everyone involved so far
approval request → approver 2 ── deny ──► STOP: DENIED notice ...
│ approve
⋮ (… every approver in the chain, in order)
last approver approves ──► APPROVED notice to everyone involved
(requester + all approvers)
```
Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps.
## Personalization placeholders
Usable in every workflow's subject and body templates:
`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{approve_url}`, `{deny_url}`.
## Security notes
- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets.
- Decision links are unguessable random tokens and work only once; a second click shows "already decided."
- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately.
- Non-admin users only ever see and use the workflows they've been granted.
## Project layout
```
app.py Flask routes, auth, admin pages
db.py SQLite schema + audit-log helpers
pipeline.py approval chain + decision/notification logic
mailer.py Purelymail SMTP sending + template rendering
templates/ pages static/style.css styling
```
@@ -1,296 +0,0 @@
"""ApprovalFlow — self-hosted email approval workflows."""
import functools
import json
import os
import re
import secrets
from flask import (Flask, abort, flash, g, redirect, render_template,
request, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
import db
import pipeline
from db import DEFAULT_TEMPLATES
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# ---------------------------------------------------------------- helpers
@app.template_filter("fromjson")
def fromjson_filter(s):
try:
return json.loads(s or "[]")
except Exception:
return []
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return s or "workflow"
def login_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return view(*a, **kw)
return wrapped
def admin_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
if not session.get("is_admin"):
abort(403)
return view(*a, **kw)
return wrapped
@app.before_request
def open_db():
g.db = db.get_db()
@app.teardown_request
def close_db(exc):
d = g.pop("db", None)
if d:
d.close()
def user_workflows(user_id):
if session.get("is_admin"):
return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall()
return g.db.execute(
"SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id "
"WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall()
# ---------------------------------------------------------------- auth
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = g.db.execute("SELECT * FROM users WHERE username=?",
(request.form.get("username", "").strip(),)).fetchone()
if user and check_password_hash(user["password_hash"], request.form.get("password", "")):
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
return redirect(request.args.get("next") or url_for("index"))
flash("Wrong username or password.", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- main page
@app.route("/", methods=["GET", "POST"])
@login_required
def index():
workflows = user_workflows(session["user_id"])
if request.method == "POST":
wf_id = request.form.get("workflow_id", type=int)
wf = next((w for w in workflows if w["id"] == wf_id), None)
if wf is None:
abort(403) # not allowed to use this workflow
subject = request.form.get("subject", "").strip()
body = request.form.get("body", "").strip()
if not subject or not body:
flash("Subject and message are both required.", "error")
else:
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
requester = user["email"] or user["username"]
rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body)
db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}")
g.db.commit()
pipeline.send_approval_request(rid)
flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok")
return redirect(url_for("index"))
my_requests = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15",
(session["username"],
(g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"),
)).fetchall()
return render_template("index.html", workflows=workflows, my_requests=my_requests)
# ---------------------------------------------------------------- decision links (from email)
@app.route("/decision/<token>/<action>", methods=["GET", "POST"])
def decision(token, action):
if action not in ("approve", "deny"):
abort(404)
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone()
if req is None:
return render_template("decision.html", stage="notfound"), 404
chain = pipeline.approver_chain(req)
approver = chain[min(req["current_step"], len(chain) - 1)] if chain else ""
if request.method == "POST":
decided_by = request.form.get("decided_by", "").strip() or approver
status, req2, message = pipeline.decide(token, action, decided_by)
return render_template("decision.html", stage="done", req=req2, message=message,
action=action, status=status)
return render_template("decision.html", stage="confirm", req=req, action=action,
approver=approver, chain=chain)
# ---------------------------------------------------------------- admin: dashboard & requests
@app.route("/admin")
@admin_required
def admin():
rows = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"ORDER BY r.id DESC LIMIT 200").fetchall()
stats = g.db.execute(
"SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
return render_template("admin.html", rows=rows, workflows=workflows,
stats={s["status"]: s["c"] for s in stats})
@app.route("/admin/request/<int:rid>", methods=["GET", "POST"])
@admin_required
def admin_request(rid):
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
if req is None:
abort(404)
if request.method == "POST" and req["status"] == "pending":
action = request.form.get("action")
if action in ("approve", "deny"):
pipeline.decide(req["token"], action, f"{session['username']} (admin)")
return redirect(url_for("admin_request", rid=rid))
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall()
participants = json.loads(req["participants"])
chain = pipeline.approver_chain(req)
return render_template("request_detail.html", req=req, logs=logs,
participants=participants, chain=chain)
# ---------------------------------------------------------------- admin: workflows
WF_FIELDS = ("approval_subject", "approval_body", "approved_subject",
"approved_body", "denied_subject", "denied_body")
@app.route("/admin/workflows/new", methods=["GET", "POST"])
@app.route("/admin/workflows/<int:wid>", methods=["GET", "POST"])
@admin_required
def workflow_edit(wid=None):
wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None
if wid and wf is None:
abort(404)
if request.method == "POST":
f = request.form
name = f.get("name", "").strip()
approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()]
if not name or not approvers:
flash("A name and at least one approver are required.", "error")
else:
templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS}
try:
if wf is None:
g.db.execute(
"INSERT INTO workflows (name, slug, description, approvers, active,"
" approval_subject, approval_body, approved_subject, approved_body,"
" denied_subject, denied_body, created_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(name, slugify(name), f.get("description", "").strip(), json.dumps(approvers),
1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], db.now()))
flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok")
else:
g.db.execute(
"UPDATE workflows SET name=?, slug=?, description=?, approvers=?,"
" active=?, approval_subject=?, approval_body=?,"
" approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?",
(name, slugify(name), f.get("description", "").strip(), json.dumps(approvers),
1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], wid))
flash(f"Workflow “{name}” saved.", "ok")
g.db.commit()
return redirect(url_for("admin"))
except Exception as e:
flash(f"Could not save: {e}", "error")
return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES)
# ---------------------------------------------------------------- admin: users & access
@app.route("/admin/users", methods=["GET", "POST"])
@admin_required
def users():
if request.method == "POST":
f = request.form
act = f.get("action")
try:
if act == "create":
if not f.get("username") or not f.get("password"):
flash("Username and password are required.", "error")
else:
g.db.execute(
"INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)",
(f["username"].strip(), generate_password_hash(f["password"]),
f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now()))
flash(f"User “{f['username']}” created.", "ok")
elif act == "delete":
uid = f.get("user_id", type=int)
if uid == session["user_id"]:
flash("You can't delete your own account.", "error")
else:
g.db.execute("DELETE FROM users WHERE id=?", (uid,))
flash("User deleted.", "ok")
elif act == "password":
g.db.execute("UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(f["password"]), f.get("user_id", type=int)))
flash("Password updated.", "ok")
elif act == "access":
uid = f.get("user_id", type=int)
g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,))
for wid in request.form.getlist("workflow_ids", type=int):
g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)",
(uid, wid))
flash("Access updated.", "ok")
g.db.commit()
except Exception as e:
flash(f"Error: {e}", "error")
return redirect(url_for("users"))
all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
access = {}
for row in g.db.execute("SELECT * FROM user_workflows"):
access.setdefault(row["user_id"], set()).add(row["workflow_id"])
return render_template("users.html", users=all_users, workflows=workflows, access=access)
# ---------------------------------------------------------------- boot
db.init_db()
if __name__ == "__main__":
app.run(host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "8000")),
debug=os.environ.get("FLASK_DEBUG") == "1")
@@ -1,176 +0,0 @@
"""SQLite data layer for ApprovalFlow."""
import json
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from werkzeug.security import generate_password_hash
DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order
active INTEGER NOT NULL DEFAULT 1,
-- personalization (templates support {placeholders})
approval_subject TEXT NOT NULL,
approval_body TEXT NOT NULL,
approved_subject TEXT NOT NULL,
approved_body TEXT NOT NULL,
denied_subject TEXT NOT NULL,
denied_body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_workflows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, workflow_id)
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL REFERENCES workflows(id),
source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site
requester TEXT NOT NULL, -- email address (or username for web)
subject TEXT NOT NULL,
body TEXT NOT NULL,
participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far
status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied
current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain
token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links
decided_by TEXT DEFAULT '',
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE,
at TEXT NOT NULL,
event TEXT NOT NULL,
detail TEXT DEFAULT ''
);
"""
DEFAULT_TEMPLATES = {
"approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}",
"approval_body": (
"A request needs your decision (approval step {step} of {total_steps}).\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Approve: {approve_url}\n"
"Deny: {deny_url}\n\n"
"If you approve, the request moves on to the next approver.\n"
"If you deny, the workflow stops and everyone involved is notified.\n"
),
"approved_subject": "[{workflow}] Approved: {subject}",
"approved_body": (
"The following request was APPROVED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
"denied_subject": "[{workflow}] Denied: {subject}",
"denied_body": (
"The following request was DENIED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
}
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
return con
def _migrate(con):
"""Upgrade a database created by the single-approver version, if present."""
cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")}
if "approver_email" in cols and "approvers" not in cols:
con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'")
for w in con.execute("SELECT id, approver_email FROM workflows").fetchall():
con.execute("UPDATE workflows SET approvers=? WHERE id=?",
(json.dumps([w["approver_email"]]), w["id"]))
print("[migrate] Converted single approver_email to approver chains.")
rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")}
if rcols and "current_step" not in rcols:
con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0")
print("[migrate] Added current_step to requests.")
con.commit()
def init_db():
con = get_db()
_migrate(con)
con.executescript(SCHEMA)
# bootstrap admin user on first run
if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0:
username = os.environ.get("ADMIN_USERNAME", "admin")
password = os.environ.get("ADMIN_PASSWORD", "changeme")
con.execute(
"INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)",
(username, generate_password_hash(password), now()),
)
print(f"[init] Created admin user '{username}'. Change the password immediately.")
con.commit()
con.close()
def log(con, request_id, event, detail=""):
con.execute(
"INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)",
(request_id, now(), event, detail),
)
def add_participants(con, request_id, emails):
"""Add people to the 'involved so far' list of a request."""
row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone()
current = json.loads(row["participants"])
for e in emails:
e = (e or "").strip().lower()
if e and e not in current:
current.append(e)
con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id))
return current
def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()):
token = secrets.token_urlsafe(32)
cur = con.execute(
"INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(workflow_id, source, requester, subject, body, token, now()),
)
rid = cur.lastrowid
add_participants(con, rid, [requester, *extra_participants])
log(con, rid, "received", f"Request received via {source} from {requester}")
return rid, token
@@ -1,9 +0,0 @@
services:
approvalflow:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
restart: unless-stopped
@@ -1,53 +0,0 @@
"""Outbound email via Purelymail SMTP, and template rendering.
The server is preconfigured for Purelymail (smtp.purelymail.com:465, SSL/TLS).
Only the account's email address and password need to be provided:
MAIL_USER=you@yourdomain.com (or you@purelymail.com)
MAIL_PASSWORD=... (use an App Password if 2FA is enabled)
"""
import os
import smtplib
from email.message import EmailMessage
SMTP_HOST = "smtp.purelymail.com"
SMTP_PORT = 465 # SSL/TLS
def render(template: str, **vars) -> str:
"""Safe {placeholder} substitution — unknown placeholders are left as-is."""
out = template
for k, v in vars.items():
out = out.replace("{" + k + "}", str(v))
return out
def send_mail(to_addrs, subject, body):
"""Send a plain-text email through Purelymail. Returns (ok, detail)."""
if isinstance(to_addrs, str):
to_addrs = [to_addrs]
to_addrs = [a for a in to_addrs if a]
if not to_addrs:
return False, "no recipients"
user = os.environ.get("MAIL_USER", "")
password = os.environ.get("MAIL_PASSWORD", "")
msg = EmailMessage()
msg["From"] = user or "approvalflow@localhost"
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not user:
# Dev mode: print instead of sending so the app is testable without an account.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
return True, "dev mode (MAIL_USER not set) — printed to console"
try:
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.login(user, password)
server.send_message(msg)
return True, f"sent to {', '.join(to_addrs)}"
except Exception as e:
return False, f"SMTP error: {e}"
@@ -1,153 +0,0 @@
"""Request pipeline for requests created on the site.
Each workflow has an ordered chain of approvers. A request moves through the
chain one approver at a time:
- DENY at any step -> the workflow STOPS; everyone involved so far gets
the denied notice.
- APPROVE -> the request moves to the next approver in the chain.
- APPROVE (last) -> the request is fully approved; everyone involved so
far gets the approved notice.
Each step gets a fresh secret token, so an earlier approver's links cannot
decide later steps.
"""
import json
import os
import secrets
import db
from mailer import render, send_mail
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
def approver_chain(wf):
try:
chain = json.loads(wf["approvers"])
except Exception:
chain = []
return [a.strip() for a in chain if a and a.strip()]
def _vars(wf, req, **extra):
chain = approver_chain(wf)
v = {
"workflow": wf["name"],
"requester": req["requester"],
"subject": req["subject"],
"body": req["body"],
"request_id": req["id"],
"step": min(req["current_step"], max(len(chain) - 1, 0)) + 1,
"total_steps": len(chain),
"approve_url": f"{BASE_URL}/decision/{req['token']}/approve",
"deny_url": f"{BASE_URL}/decision/{req['token']}/deny",
}
v.update(extra)
return v
def current_approver(wf, req):
chain = approver_chain(wf)
if not chain:
return None
return chain[min(req["current_step"], len(chain) - 1)]
def send_approval_request(request_id):
"""Send the approval request to the approver of the CURRENT step."""
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
chain = approver_chain(wf)
approver = current_approver(wf, req)
if approver is None:
db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured")
con.commit()
return False
v = _vars(wf, req)
ok, detail = send_mail(approver,
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v))
db.add_participants(con, request_id, [approver])
db.log(con, request_id,
"approval requested" if ok else "mail error",
f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver}{detail}")
con.commit()
return ok
finally:
con.close()
def _notify_outcome(con, wf, req, new_status, decided_by):
participants = db.add_participants(con, req["id"], [decided_by])
v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper())
subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"]
body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"]
ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v))
db.log(con, req["id"],
"participants notified" if ok else "mail error",
f"{new_status.capitalize()} notice to {', '.join(participants)}{detail}")
def decide(token, action, decided_by):
"""Handle a decision at the request's current step.
Returns (status, request_row_or_None, message).
"""
assert action in ("approve", "deny")
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone()
if req is None:
return "notfound", None, "This link is not valid — it may belong to a step that was already decided."
if req["status"] != "pending":
return "already", req, f"This request was already {req['status']}."
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
chain = approver_chain(wf)
step = req["current_step"]
if action == "deny":
# STOP the workflow, notify everyone involved so far.
con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=? WHERE id=?",
(decided_by, db.now(), req["id"]))
db.log(con, req["id"], "denied",
f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped")
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
_notify_outcome(con, wf, req, "denied", decided_by)
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. "
"The workflow has stopped and everyone involved has been notified.")
# approve
db.log(con, req["id"], "step approved",
f"Step {step + 1}/{len(chain)} approved by {decided_by}")
db.add_participants(con, req["id"], [decided_by])
if step + 1 >= len(chain):
# Last approver — fully approved.
con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?",
(decided_by, db.now(), req["id"]))
db.log(con, req["id"], "approved",
f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}")
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
_notify_outcome(con, wf, req, "approved", decided_by)
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). "
"Everyone involved has been notified.")
# Move to the next approver with a fresh token (old links stop working).
new_token = secrets.token_urlsafe(32)
con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?",
(step + 1, new_token, req["id"]))
con.commit()
send_approval_request(req["id"])
req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the "
f"next approver ({chain[step + 1]}).")
finally:
con.close()

Some files were not shown because too many files have changed in this diff Show More