v_2.3
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env.local
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite*
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
node_modules/
|
||||||
|
tests/
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# AlertHub Configuration
|
||||||
|
# All configuration is done here. NEVER commit your real .env to source control.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---- Flask Core ----
|
||||||
|
# Generate a strong key: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=change-me-to-a-long-random-string-please-generate-with-secrets-module
|
||||||
|
FLASK_ENV=production
|
||||||
|
|
||||||
|
# ---- Ports ----
|
||||||
|
# AlertHub now runs as a SINGLE service on one port. The admin backoffice is
|
||||||
|
# served on the same port under the /backoffice path (admins only).
|
||||||
|
FRONTOFFICE_PORT=8080
|
||||||
|
|
||||||
|
# BACKOFFICE_PORT is no longer used (the backoffice shares the main port under
|
||||||
|
# /backoffice). It is kept here only so old .env files remain valid; you can
|
||||||
|
# safely delete it.
|
||||||
|
# BACKOFFICE_PORT=9001
|
||||||
|
|
||||||
|
# ---- Gunicorn (optional) ----
|
||||||
|
# Keep GUNICORN_WORKERS at 1 so the reminder scheduler runs exactly once.
|
||||||
|
# Increase GUNICORN_THREADS for more concurrency instead.
|
||||||
|
GUNICORN_WORKERS=1
|
||||||
|
GUNICORN_THREADS=4
|
||||||
|
|
||||||
|
# ---- Database ----
|
||||||
|
DATABASE_PATH=/data/alerthub.db
|
||||||
|
|
||||||
|
# ---- Initial Administrator ----
|
||||||
|
# There is NO seeded admin any more. On first launch, open the app and you'll be
|
||||||
|
# taken to a one-time "Create administrator" screen to set the username &
|
||||||
|
# password yourself. (The variables below are legacy/no-ops, kept for reference.)
|
||||||
|
# INITIAL_ADMIN_USERNAME=admin
|
||||||
|
# INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
# INITIAL_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ---- Session / Security ----
|
||||||
|
# Set SESSION_COOKIE_SECURE=true if you serve via HTTPS (recommended in production)
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
SESSION_LIFETIME_HOURS=8
|
||||||
|
PASSWORD_MIN_LENGTH=10
|
||||||
|
|
||||||
|
# ---- MFA ----
|
||||||
|
# MFA is optional and self-service: any user can enable it from Account → MFA.
|
||||||
|
MFA_ISSUER_NAME=AlertHub
|
||||||
|
|
||||||
|
# ---- Scheduler ----
|
||||||
|
# How often (in minutes) the scheduler checks alerts and dispatches reminder emails
|
||||||
|
SCHEDULER_INTERVAL_MINUTES=60
|
||||||
|
|
||||||
|
# ---- Default Reminder Lead Times (in days) ----
|
||||||
|
# Comma-separated list. Configurable per-alert later, but these are defaults.
|
||||||
|
DEFAULT_REMINDER_DAYS=30,14,7,1
|
||||||
|
|
||||||
|
# ---- SMTP / Outgoing mail (Purelymail defaults) ----
|
||||||
|
# Note: these values seed the DB on first run only. After that, edit them in
|
||||||
|
# the backoffice at /mail-config.
|
||||||
|
# Purelymail SMTP: smtp.purelymail.com : 465 (SSL/TLS)
|
||||||
|
# If your client only supports STARTTLS, use port 587 with SMTP_ENCRYPTION=starttls
|
||||||
|
SMTP_HOST=smtp.purelymail.com
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USERNAME=you@yourdomain.com
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_ENCRYPTION=ssl
|
||||||
|
# Allowed: none | starttls | ssl
|
||||||
|
SMTP_FROM_ADDRESS=you@yourdomain.com
|
||||||
|
SMTP_FROM_NAME=AlertHub Notifications
|
||||||
|
|
||||||
|
# ---- IMAP / Sent-folder copy (Purelymail defaults) ----
|
||||||
|
# When a mail is sent from the platform, a copy is appended to the mailbox's
|
||||||
|
# Sent folder over IMAP so it shows up in your normal mail client.
|
||||||
|
# Purelymail IMAP: imap.purelymail.com : 993 (SSL/TLS)
|
||||||
|
# Leave IMAP_USERNAME / IMAP_PASSWORD blank to reuse the SMTP credentials.
|
||||||
|
IMAP_HOST=imap.purelymail.com
|
||||||
|
IMAP_PORT=993
|
||||||
|
IMAP_USERNAME=
|
||||||
|
IMAP_PASSWORD=
|
||||||
|
IMAP_SENT_FOLDER=Sent
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite*
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# ---- Build / runtime image -------------------------------------------------
|
||||||
|
# python:3.13-slim-trixie is the current stable Debian-based slim variant.
|
||||||
|
FROM python:3.13-slim-trixie
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# Install only what we strictly need (curl for healthchecks).
|
||||||
|
# Use --no-install-recommends, then immediately purge apt lists to keep
|
||||||
|
# the image small.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create an unprivileged user
|
||||||
|
RUN groupadd --system --gid 10001 alerthub \
|
||||||
|
&& useradd --system --uid 10001 --gid alerthub --home-dir /app --shell /usr/sbin/nologin alerthub
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies first (better layer cache)
|
||||||
|
COPY --chown=alerthub:alerthub requirements.txt ./
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application sources
|
||||||
|
COPY --chown=alerthub:alerthub app/ ./app/
|
||||||
|
COPY --chown=alerthub:alerthub wsgi.py wsgi_frontoffice.py wsgi_backoffice.py entrypoint.sh ./
|
||||||
|
|
||||||
|
RUN chmod +x entrypoint.sh \
|
||||||
|
&& mkdir -p /data \
|
||||||
|
&& chown -R alerthub:alerthub /app /data
|
||||||
|
|
||||||
|
USER alerthub
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Start the unified app (frontoffice + /backoffice) on a single port.
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=4s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:${FRONTOFFICE_PORT:-8080}/login >/dev/null || exit 1
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,144 @@
|
|||||||
|
# AlertHub
|
||||||
|
|
||||||
|
**Version 1.5 — Martinhal IT (Joao Vaz)**
|
||||||
|
|
||||||
|
A Dockerized alert/reminder web application with email notifications, MFA-capable authentication, and role-based access control. Designed for tracking expiration dates (licenses, renewals, certificates, contracts) with live countdown timers and configurable email reminders.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **First-run admin setup** — On the very first launch (no accounts yet) you're taken to a "Create administrator" screen to set the admin username & password yourself. No seeded/default admin credentials.
|
||||||
|
- **Single-port architecture** — Everything runs on one port (`:8080`). The frontoffice (alerts + management) is at the root; the admin backoffice (users, mail, settings, audit log, mail log) is served on the **same port** under `/backoffice` and is restricted to admins.
|
||||||
|
- **Backoffice shortcut** — Admins get a **Backoffice** link in the top bar (and a **Frontoffice** link back) that switches between the two areas. Only visible to admins.
|
||||||
|
- **Authentication** — Password (bcrypt, 12 rounds) with optional TOTP MFA. **Any user** can self-enable MFA from *Account → MFA*; it is never mandatory.
|
||||||
|
- **User avatars** — Any user can upload a profile picture (PNG/JPG/GIF/WebP). Images are auto-squared and resized to 256×256 and stored in the database (survives upgrades). Shown in the top bar.
|
||||||
|
- **RBAC** — Two roles: `user` (view-only) and `admin` (full access)
|
||||||
|
- **Live countdown timers** — Per-alert ticking timers (days/hours/minutes/seconds) with state-based color coding (warning, critical, expired)
|
||||||
|
- **Email reminders** — Configurable SMTP + IMAP editable from the backoffice. Per-alert override of reminder lead times.
|
||||||
|
- **Mail log** — Every message the platform sends is recorded in the backoffice **Mail Log** (recipients, subject, delivery status, whether the Sent-folder copy was saved, and who triggered it).
|
||||||
|
- **Sent-folder copy (IMAP)** — After each successful send, a copy of the message is appended to the mailbox's **Sent** folder over IMAP, so it appears in your normal mail client.
|
||||||
|
- **Log export & email** — Both the Audit log and Mail log can be exported to CSV or emailed (as a CSV attachment) to any destination address.
|
||||||
|
- **Account security** — Lockout after 5 failed attempts (15-minute window), audit log of sensitive actions, security headers (CSP, X-Frame-Options, HSTS-ready)
|
||||||
|
- **IT-terminal aesthetic** — JetBrains Mono + IBM Plex Sans, signal-green accents, light + dark mode (auto-detect + manual toggle)
|
||||||
|
- **Consistent footer** — `© 2026 Martinhal IT - Joao Vaz - Version 1.5` on every page except the login screen.
|
||||||
|
- **Hardened container** — Non-root user, all capabilities dropped, `no-new-privileges`, healthcheck
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Python 3.13 · Flask 3.1 · SQLAlchemy 2 · Flask-Login · Flask-WTF · APScheduler · pyotp · bcrypt · gunicorn · SQLite
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Get the code, then:
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. Edit .env — at minimum, change:
|
||||||
|
# - SECRET_KEY (use: python -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
# - SMTP_* / IMAP_* (your Purelymail credentials)
|
||||||
|
|
||||||
|
# 3. Build and start:
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4. Open:
|
||||||
|
# App: http://localhost:8080
|
||||||
|
# Backoffice: http://localhost:8080/backoffice (admins only)
|
||||||
|
|
||||||
|
# 5. First visit → you'll be sent to the "Create administrator" screen.
|
||||||
|
# Set the admin username, email, and password there.
|
||||||
|
```
|
||||||
|
|
||||||
|
### First-run checklist
|
||||||
|
|
||||||
|
1. Open the app — you'll land on **Create administrator**. Set the admin username, email and password.
|
||||||
|
2. Sign in with the credentials you just created.
|
||||||
|
3. (Optional) Go to **Account** → **Set up MFA** (scan the QR code with Google Authenticator, Authy, 1Password, etc.). Any user can do this.
|
||||||
|
4. (Optional) **Account** → **Profile avatar** → upload a picture.
|
||||||
|
5. Open the backoffice → **Mail** → confirm the Purelymail SMTP/IMAP settings, fill in the mailbox username/password, and send a test email. Check the **Mail Log** to confirm it sent and that a copy was saved to the mailbox's Sent folder.
|
||||||
|
6. Backoffice → **Settings** → set the default reminder lead times (e.g. `30,14,7,1` days before expiry).
|
||||||
|
7. Frontoffice → **Manage Alerts** → add your first alert.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration lives in `.env`. The `docker-compose.yml` only references the file via `env_file` — no secrets are baked into the image or compose file.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `SECRET_KEY` | Flask session signing key (required, generate a new one) |
|
||||||
|
| `FRONTOFFICE_PORT` | The single host port (default 8080). `BACKOFFICE_PORT` is no longer used. |
|
||||||
|
| `GUNICORN_WORKERS` / `GUNICORN_THREADS` | Web server sizing. Keep workers at 1 so the reminder scheduler runs once (default 1 worker / 4 threads). |
|
||||||
|
| `DATABASE_PATH` | SQLite path inside the container (default `/data/alerthub.db`) |
|
||||||
|
| `SESSION_LIFETIME_HOURS` | Login session length |
|
||||||
|
| `MAX_LOGIN_ATTEMPTS` / `LOCKOUT_MINUTES` | Brute-force protection |
|
||||||
|
| `SCHEDULER_INTERVAL_MINUTES` | How often the reminder job runs (default 60) |
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_ENCRYPTION` / `SMTP_FROM_ADDRESS` / `SMTP_FROM_NAME` | Initial outgoing-mail config (Purelymail defaults; seeded into DB on first run; admins edit live values via backoffice afterwards) |
|
||||||
|
| `IMAP_HOST` / `IMAP_PORT` / `IMAP_USERNAME` / `IMAP_PASSWORD` / `IMAP_SENT_FOLDER` | IMAP settings for saving a copy of sent mail to the Sent folder (Purelymail defaults). Leave user/pass blank to reuse the SMTP credentials. |
|
||||||
|
| `DEFAULT_REMINDER_DAYS` | Default reminder thresholds, comma-separated (e.g. `30,14,7,1`) |
|
||||||
|
|
||||||
|
> **Admin account:** There is no seeded admin. The first launch presents a one-time **Create administrator** screen. The old `INITIAL_ADMIN_*` variables are no longer used.
|
||||||
|
|
||||||
|
> **Note:** `SMTP_*` / `IMAP_*` values seed the database on first launch. After that, the backoffice **Mail** page is the source of truth — edits there persist independently of `.env`.
|
||||||
|
|
||||||
|
### Purelymail mail settings
|
||||||
|
|
||||||
|
Defaults ship pre-configured for Purelymail:
|
||||||
|
|
||||||
|
- **Outgoing (SMTP):** `smtp.purelymail.com`, port **465**, SSL/TLS. (If your client only supports STARTTLS, use port **587** with `SMTP_ENCRYPTION=starttls`.)
|
||||||
|
- **Incoming (IMAP):** `imap.purelymail.com`, port **993**, SSL/TLS — used to append sent messages to the **Sent** folder.
|
||||||
|
|
||||||
|
Just fill in your mailbox username and password (in `.env` or the backoffice **Mail** page).
|
||||||
|
|
||||||
|
## Upgrading in place (no data loss)
|
||||||
|
|
||||||
|
This build is designed to be **unzipped over the previous version**. On startup the app runs additive migrations: it creates any new tables (e.g. the mail log) and adds any new columns (avatars) to the existing SQLite database in the `/data` volume, without touching your existing users, alerts, settings, or audit history.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the folder containing your existing docker-compose.yml / .env / app:
|
||||||
|
unzip -o alerthub.zip # overwrite code in place (keeps .env and the /data volume)
|
||||||
|
docker compose up -d --build # rebuild & restart; migrations run automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
Your customised SMTP settings are preserved on upgrade; the new IMAP keys are seeded with the Purelymail defaults (adjust them in the backoffice **Mail** page).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── Docker container ────────────┐
|
||||||
|
│ │
|
||||||
|
│ entrypoint.sh │
|
||||||
|
│ └── gunicorn wsgi:application :8080 │
|
||||||
|
│ ├── frontoffice (/) │
|
||||||
|
│ ├── backoffice (/backoffice) │
|
||||||
|
│ └── scheduler (APScheduler) │
|
||||||
|
│ │
|
||||||
|
│ /data/alerthub.db (named volume) │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Both processes share the same SQLite database (mounted as a Docker named volume). The reminder scheduler runs only inside the frontoffice process to avoid duplicate emails.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Passwords stored as bcrypt hashes (cost 12).
|
||||||
|
- TOTP secrets stored encrypted at rest is NOT implemented — the secret is stored as-is. If your threat model requires it, mount the DB on an encrypted volume.
|
||||||
|
- CSP is strict (no inline scripts except the early theme bootstrap which is `nonce`-able if you tighten further).
|
||||||
|
- Container runs as uid `10001`, drops all Linux capabilities, and uses `no-new-privileges`.
|
||||||
|
- Brute-force lockout is per-username, not per-IP.
|
||||||
|
|
||||||
|
## Operational tips
|
||||||
|
|
||||||
|
- **Trigger a reminder check manually:** Backoffice → Settings → "Run check now".
|
||||||
|
- **Reset a user's MFA:** Backoffice → Users → row menu → Reset MFA.
|
||||||
|
- **Unlock a locked-out user:** Backoffice → Users → row menu → Unlock.
|
||||||
|
- **Inspect activity:** Backoffice → Audit log (paginated).
|
||||||
|
- **Reset reminder history for an alert** (e.g. after extending the date): Manage Alerts → row → Reset reminders.
|
||||||
|
|
||||||
|
## Uninstall / data reset
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down # stop containers, keep data
|
||||||
|
docker compose down -v # also remove the alerthub_data volume (deletes DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Provided as-is for internal use. No warranty.
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Application factory.
|
||||||
|
|
||||||
|
Builds a single unified Flask app served on ONE port:
|
||||||
|
- frontoffice at the root: / (View) and /alerts (Add/Edit/Remove)
|
||||||
|
- backoffice under /backoffice: users, mail config, settings, audit log,
|
||||||
|
mail log — all restricted to admins.
|
||||||
|
|
||||||
|
Everything shares the same database, models, and login system. The ``variant``
|
||||||
|
argument is retained for backwards compatibility with existing WSGI entry
|
||||||
|
points but no longer changes what is registered — both areas are always
|
||||||
|
available on the same app.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from flask import Flask, redirect, url_for, render_template, request, session
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .models import db, User, AppSetting
|
||||||
|
from .scheduler import init_scheduler
|
||||||
|
|
||||||
|
APP_VERSION = "1.5"
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging():
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations(app: Flask):
|
||||||
|
"""Lightweight, additive schema migrations so an existing DB can be
|
||||||
|
upgraded in place (unzip-over-the-top) without any data loss.
|
||||||
|
|
||||||
|
Only ADDs missing tables/columns — never drops or alters existing data.
|
||||||
|
Every step is idempotent and tolerant of a concurrent process having
|
||||||
|
already applied it (both gunicorn apps boot against the same file).
|
||||||
|
"""
|
||||||
|
with app.app_context():
|
||||||
|
# 1. Create any brand-new tables (e.g. mail_log) and missing tables.
|
||||||
|
try:
|
||||||
|
db.create_all()
|
||||||
|
except OperationalError as e:
|
||||||
|
# Another booting process created a table at the same moment.
|
||||||
|
app.logger.info("create_all raced (ignored): %s", e)
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
# 2. Add any missing columns on the users table.
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
existing_cols = {c["name"] for c in inspector.get_columns("users")}
|
||||||
|
add_column = {
|
||||||
|
"avatar_data": "ALTER TABLE users ADD COLUMN avatar_data BLOB",
|
||||||
|
"avatar_mime": "ALTER TABLE users ADD COLUMN avatar_mime VARCHAR(64)",
|
||||||
|
}
|
||||||
|
for col, ddl in add_column.items():
|
||||||
|
if col in existing_cols:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with db.engine.begin() as conn:
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
app.logger.info("Migration: added users.%s", col)
|
||||||
|
except OperationalError as e:
|
||||||
|
# "duplicate column name" — another process won the race. Fine.
|
||||||
|
app.logger.info("Add column %s raced (ignored): %s", col, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_settings(app: Flask):
|
||||||
|
"""Seed default settings (mail config, IMAP, reminders) when rows are
|
||||||
|
missing. Never overwrites values an admin has already set — so upgrades
|
||||||
|
keep existing configuration. New keys (e.g. IMAP) are seeded on upgrade.
|
||||||
|
|
||||||
|
Uses an INSERT that tolerates a concurrent process seeding the same key.
|
||||||
|
"""
|
||||||
|
with app.app_context():
|
||||||
|
defaults = {
|
||||||
|
# ---- SMTP (outgoing) — Purelymail defaults ----
|
||||||
|
"smtp_host": app.config.get("SMTP_HOST", "smtp.purelymail.com"),
|
||||||
|
"smtp_port": str(app.config.get("SMTP_PORT", 465)),
|
||||||
|
"smtp_username": app.config.get("SMTP_USERNAME", ""),
|
||||||
|
"smtp_password": app.config.get("SMTP_PASSWORD", ""),
|
||||||
|
"smtp_encryption": app.config.get("SMTP_ENCRYPTION", "ssl"),
|
||||||
|
"smtp_from_address": app.config.get("SMTP_FROM_ADDRESS", ""),
|
||||||
|
"smtp_from_name": app.config.get("SMTP_FROM_NAME", "AlertHub"),
|
||||||
|
# ---- IMAP (Sent-folder copy) — Purelymail defaults ----
|
||||||
|
"imap_enabled": "true",
|
||||||
|
"imap_host": app.config.get("IMAP_HOST", "imap.purelymail.com"),
|
||||||
|
"imap_port": str(app.config.get("IMAP_PORT", 993)),
|
||||||
|
"imap_username": app.config.get("IMAP_USERNAME", ""),
|
||||||
|
"imap_password": app.config.get("IMAP_PASSWORD", ""),
|
||||||
|
"imap_sent_folder": app.config.get("IMAP_SENT_FOLDER", "Sent"),
|
||||||
|
# ---- Reminders ----
|
||||||
|
"default_reminder_days": app.config.get("DEFAULT_REMINDER_DAYS", "30,14,7,1"),
|
||||||
|
}
|
||||||
|
for k, v in defaults.items():
|
||||||
|
if AppSetting.get(k) is not None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = AppSetting(key=k, value=v)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# A concurrent boot inserted this key first — that's fine.
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def _initialise_database(app: Flask):
|
||||||
|
"""Run migrations + seeding exactly once at a time across processes.
|
||||||
|
|
||||||
|
The container starts several worker processes that all import the app and
|
||||||
|
hit the shared SQLite file simultaneously. A cross-process file lock
|
||||||
|
serialises initialisation so concurrent DDL/seeding can't collide.
|
||||||
|
"""
|
||||||
|
import fcntl # POSIX-only; the container is Linux.
|
||||||
|
|
||||||
|
db_dir = os.path.dirname(app.config["DATABASE_PATH"]) or "."
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
lock_path = os.path.join(db_dir, ".alerthub-init.lock")
|
||||||
|
|
||||||
|
try:
|
||||||
|
lock_file = open(lock_path, "w")
|
||||||
|
except OSError:
|
||||||
|
# If we somehow can't open the lock file, fall back to unlocked init;
|
||||||
|
# the per-step tolerance above still guards against most races.
|
||||||
|
_run_migrations(app)
|
||||||
|
_seed_settings(app)
|
||||||
|
return
|
||||||
|
|
||||||
|
with lock_file:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||||
|
except OSError:
|
||||||
|
pass # advisory lock unavailable — proceed; steps are tolerant.
|
||||||
|
try:
|
||||||
|
_run_migrations(app)
|
||||||
|
_seed_settings(app)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(variant: str = "frontoffice") -> Flask:
|
||||||
|
"""Build the unified Flask app (frontoffice + /backoffice on one port).
|
||||||
|
|
||||||
|
``variant`` is accepted for backwards compatibility with the existing WSGI
|
||||||
|
entry points but no longer restricts which blueprints are registered.
|
||||||
|
"""
|
||||||
|
if variant not in ("frontoffice", "backoffice"):
|
||||||
|
variant = "frontoffice"
|
||||||
|
|
||||||
|
_setup_logging()
|
||||||
|
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder="templates",
|
||||||
|
static_folder="static",
|
||||||
|
)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
app.config["APP_VARIANT"] = variant
|
||||||
|
|
||||||
|
# Make sure data dir exists
|
||||||
|
os.makedirs(os.path.dirname(app.config["DATABASE_PATH"]), exist_ok=True)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = "auth.login"
|
||||||
|
login_manager.login_message = "Please sign in to continue."
|
||||||
|
login_manager.login_message_category = "info"
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
try:
|
||||||
|
return db.session.get(User, int(user_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- Blueprints ----
|
||||||
|
# Unified app: the frontoffice lives at the root and the backoffice is
|
||||||
|
# mounted under /backoffice, so both are served on the SAME port. Access to
|
||||||
|
# backoffice views is still restricted to admins by @admin_required.
|
||||||
|
from .routes_auth import auth_bp
|
||||||
|
from .routes_main import main_bp
|
||||||
|
from .routes_backoffice import backoffice_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
app.register_blueprint(backoffice_bp, url_prefix="/backoffice")
|
||||||
|
|
||||||
|
# ---- Error handlers ----
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden(_):
|
||||||
|
return render_template("error.html", code=403, message="You don't have permission to view this page."), 403
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(_):
|
||||||
|
return render_template("error.html", code=404, message="Page not found."), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def server_error(_):
|
||||||
|
return render_template("error.html", code=500, message="Internal server error."), 500
|
||||||
|
|
||||||
|
# ---- Security headers ----
|
||||||
|
@app.after_request
|
||||||
|
def add_security_headers(response):
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||||
|
# Reasonable CSP for our self-hosted assets + the QR data URI
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; img-src 'self' data:; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; script-src 'self'; "
|
||||||
|
"font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ---- Template context: brand + which area we're in ----
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
# The backoffice is now mounted under /backoffice on the same origin,
|
||||||
|
# so cross-links are simple same-site URLs and "is_backoffice" is
|
||||||
|
# determined by which blueprint served the current request.
|
||||||
|
is_bo = (request.blueprint == "backoffice") if request else False
|
||||||
|
try:
|
||||||
|
backoffice_url = url_for("backoffice.dashboard")
|
||||||
|
frontoffice_url = url_for("main.index")
|
||||||
|
except Exception:
|
||||||
|
backoffice_url = "/backoffice/"
|
||||||
|
frontoffice_url = "/"
|
||||||
|
return {
|
||||||
|
"app_variant": "backoffice" if is_bo else "frontoffice",
|
||||||
|
"brand_name": "AlertHub",
|
||||||
|
"is_backoffice": is_bo,
|
||||||
|
"app_version": APP_VERSION,
|
||||||
|
"footer_text": "© 2026 Martinhal IT - Joao Vaz - Version " + APP_VERSION,
|
||||||
|
"backoffice_url": backoffice_url,
|
||||||
|
"frontoffice_url": frontoffice_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Initialise DB (additive migrations) + seed default settings.
|
||||||
|
# Serialised across the container's worker processes via a file lock. ----
|
||||||
|
_initialise_database(app)
|
||||||
|
|
||||||
|
# ---- First-run guard: if there are no users yet, force the operator
|
||||||
|
# through the "create administrator" setup screen before anything else. ----
|
||||||
|
app._alerthub_has_users = False
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _require_initial_setup():
|
||||||
|
if app._alerthub_has_users:
|
||||||
|
return None
|
||||||
|
# Allow static assets and the setup endpoint itself through.
|
||||||
|
if request.endpoint in ("static", "auth.setup"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if User.query.count() > 0:
|
||||||
|
app._alerthub_has_users = True
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return redirect(url_for("auth.setup"))
|
||||||
|
|
||||||
|
# ---- Scheduler. init_scheduler() is idempotent (guarded by a module-level
|
||||||
|
# singleton), and the container runs a single Gunicorn worker, so exactly
|
||||||
|
# one reminder scheduler runs regardless of how many times this is called. ----
|
||||||
|
init_scheduler(app)
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Auth helpers and decorators."""
|
||||||
|
from functools import wraps
|
||||||
|
from flask import abort, redirect, url_for, session, flash, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for("auth.login", next=request.full_path))
|
||||||
|
if not current_user.is_admin:
|
||||||
|
abort(403)
|
||||||
|
if session.get("mfa_pending"):
|
||||||
|
return redirect(url_for("auth.mfa_challenge"))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def login_required_full(view):
|
||||||
|
"""Like flask_login.login_required, but also blocks users with pending MFA."""
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for("auth.login", next=request.full_path))
|
||||||
|
if session.get("mfa_pending"):
|
||||||
|
return redirect(url_for("auth.mfa_challenge"))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def get_remote_ip() -> str:
|
||||||
|
"""Best-effort IP detection for audit logs."""
|
||||||
|
fwd = request.headers.get("X-Forwarded-For", "")
|
||||||
|
if fwd:
|
||||||
|
return fwd.split(",")[0].strip()
|
||||||
|
return request.remote_addr or ""
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Configuration loaded from environment variables."""
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(val: str, default: bool = False) -> bool:
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-replace-me")
|
||||||
|
FLASK_ENV = os.environ.get("FLASK_ENV", "production")
|
||||||
|
|
||||||
|
DATABASE_PATH = os.environ.get("DATABASE_PATH", "/data/alerthub.db")
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_PATH}"
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
# Wait (instead of erroring) when another process holds the SQLite
|
||||||
|
# write lock — both gunicorn apps share one database file.
|
||||||
|
"connect_args": {"timeout": 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bootstrap admin (only used on first DB init)
|
||||||
|
INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME", "admin")
|
||||||
|
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL", "admin@example.com")
|
||||||
|
INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD", "ChangeMeOnFirstLogin!")
|
||||||
|
|
||||||
|
# Session security
|
||||||
|
SESSION_COOKIE_SECURE = _bool(os.environ.get("SESSION_COOKIE_SECURE"), False)
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(
|
||||||
|
hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "8"))
|
||||||
|
)
|
||||||
|
|
||||||
|
PASSWORD_MIN_LENGTH = int(os.environ.get("PASSWORD_MIN_LENGTH", "10"))
|
||||||
|
|
||||||
|
# MFA
|
||||||
|
MFA_ISSUER_NAME = os.environ.get("MFA_ISSUER_NAME", "AlertHub")
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
SCHEDULER_INTERVAL_MINUTES = int(os.environ.get("SCHEDULER_INTERVAL_MINUTES", "60"))
|
||||||
|
|
||||||
|
# Default reminder lead times in days
|
||||||
|
DEFAULT_REMINDER_DAYS = os.environ.get("DEFAULT_REMINDER_DAYS", "30,14,7,1")
|
||||||
|
|
||||||
|
# SMTP defaults (seed only — admins edit live values via backoffice)
|
||||||
|
# Purelymail outgoing: smtp.purelymail.com : 465 (SSL/TLS)
|
||||||
|
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.purelymail.com")
|
||||||
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
||||||
|
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
|
||||||
|
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||||
|
SMTP_ENCRYPTION = os.environ.get("SMTP_ENCRYPTION", "ssl")
|
||||||
|
SMTP_FROM_ADDRESS = os.environ.get("SMTP_FROM_ADDRESS", "")
|
||||||
|
SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AlertHub")
|
||||||
|
|
||||||
|
# IMAP defaults (seed only) — used to save a copy of sent mail to the
|
||||||
|
# mailbox's Sent folder. Purelymail incoming: imap.purelymail.com : 993 (SSL/TLS)
|
||||||
|
IMAP_HOST = os.environ.get("IMAP_HOST", "imap.purelymail.com")
|
||||||
|
IMAP_PORT = int(os.environ.get("IMAP_PORT", "993"))
|
||||||
|
IMAP_USERNAME = os.environ.get("IMAP_USERNAME", "")
|
||||||
|
IMAP_PASSWORD = os.environ.get("IMAP_PASSWORD", "")
|
||||||
|
IMAP_SENT_FOLDER = os.environ.get("IMAP_SENT_FOLDER", "Sent")
|
||||||
|
|
||||||
|
# Ports (used by run scripts only)
|
||||||
|
FRONTOFFICE_PORT = int(os.environ.get("FRONTOFFICE_PORT", "8080"))
|
||||||
|
BACKOFFICE_PORT = int(os.environ.get("BACKOFFICE_PORT", "9001"))
|
||||||
|
|
||||||
|
# Optional explicit public URLs for the cross-console links in the top bar.
|
||||||
|
# If empty, the app derives them from the request host + the ports above
|
||||||
|
# (e.g. http://<your-host>:9001/). Set these when behind a reverse proxy
|
||||||
|
# or when the consoles are reached at custom hostnames/paths.
|
||||||
|
BACKOFFICE_PUBLIC_URL = os.environ.get("BACKOFFICE_PUBLIC_URL", "")
|
||||||
|
FRONTOFFICE_PUBLIC_URL = os.environ.get("FRONTOFFICE_PUBLIC_URL", "")
|
||||||
|
|
||||||
|
# Resource limits
|
||||||
|
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 # 2MB upload cap
|
||||||
|
WTF_CSRF_TIME_LIMIT = 3600
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""WTForms — all forms get CSRF protection automatically via Flask-WTF."""
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField, IntegerField, SelectField, DateField, TextAreaField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional, NumberRange, Regexp
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
username = StringField("Username", validators=[DataRequired(), Length(min=2, max=64)])
|
||||||
|
password = PasswordField("Password", validators=[DataRequired(), Length(min=1, max=200)])
|
||||||
|
submit = SubmitField("Sign in")
|
||||||
|
|
||||||
|
|
||||||
|
class SetupAdminForm(FlaskForm):
|
||||||
|
"""First-run form: create the very first administrator account."""
|
||||||
|
username = StringField("Admin username", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=2, max=64),
|
||||||
|
Regexp(r"^[A-Za-z0-9_.\-]+$", message="Letters, digits, underscore, dot, dash only."),
|
||||||
|
])
|
||||||
|
email = StringField("Admin email", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
password = PasswordField("Password", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=10, max=200, message="Min 10 characters"),
|
||||||
|
])
|
||||||
|
confirm = PasswordField("Confirm password", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
EqualTo("password", message="Passwords must match."),
|
||||||
|
])
|
||||||
|
submit = SubmitField("Create administrator")
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarForm(FlaskForm):
|
||||||
|
avatar = FileField("Avatar image", validators=[
|
||||||
|
FileRequired(message="Choose an image file."),
|
||||||
|
FileAllowed(["png", "jpg", "jpeg", "gif", "webp"], "Images only (png, jpg, gif, webp)."),
|
||||||
|
])
|
||||||
|
submit = SubmitField("Upload avatar")
|
||||||
|
|
||||||
|
|
||||||
|
class MfaForm(FlaskForm):
|
||||||
|
code = StringField("Authentication code", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=6, max=6, message="Must be 6 digits"),
|
||||||
|
Regexp(r"^\d{6}$", message="Digits only"),
|
||||||
|
])
|
||||||
|
submit = SubmitField("Verify")
|
||||||
|
|
||||||
|
|
||||||
|
class MfaSetupForm(FlaskForm):
|
||||||
|
code = StringField("Verification code", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=6, max=6),
|
||||||
|
Regexp(r"^\d{6}$"),
|
||||||
|
])
|
||||||
|
submit = SubmitField("Enable MFA")
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordForm(FlaskForm):
|
||||||
|
current_password = PasswordField("Current password", validators=[DataRequired()])
|
||||||
|
new_password = PasswordField("New password", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=10, max=200, message="Min 10 characters"),
|
||||||
|
])
|
||||||
|
confirm = PasswordField("Confirm new password", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
EqualTo("new_password", message="Passwords must match."),
|
||||||
|
])
|
||||||
|
submit = SubmitField("Change password")
|
||||||
|
|
||||||
|
|
||||||
|
class AlertForm(FlaskForm):
|
||||||
|
title = StringField("Title", validators=[DataRequired(), Length(max=200)])
|
||||||
|
category = StringField("Category", validators=[Optional(), Length(max=64)])
|
||||||
|
description = TextAreaField("Description", validators=[Optional(), Length(max=5000)])
|
||||||
|
expiration_date = DateField("Expiration date", validators=[DataRequired()], format="%Y-%m-%d")
|
||||||
|
reminder_days = StringField(
|
||||||
|
"Reminder days (comma-separated, days before expiration)",
|
||||||
|
validators=[DataRequired(), Regexp(r"^[\d,\s]+$", message="Numbers separated by commas only")],
|
||||||
|
default="30,14,7,1",
|
||||||
|
)
|
||||||
|
is_active = BooleanField("Active", default=True)
|
||||||
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
|
class UserForm(FlaskForm):
|
||||||
|
username = StringField("Username", validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=2, max=64),
|
||||||
|
Regexp(r"^[A-Za-z0-9_.\-]+$", message="Letters, digits, underscore, dot, dash only."),
|
||||||
|
])
|
||||||
|
email = StringField("Email", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
role = SelectField("Role", choices=[("user", "User (view only)"), ("admin", "Administrator")])
|
||||||
|
is_active = BooleanField("Active", default=True)
|
||||||
|
password = PasswordField("Password (leave blank to keep current)", validators=[Optional(), Length(min=10, max=200)])
|
||||||
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
|
class MailConfigForm(FlaskForm):
|
||||||
|
smtp_host = StringField("SMTP Host", validators=[DataRequired(), Length(max=255)])
|
||||||
|
smtp_port = IntegerField("Port", validators=[DataRequired(), NumberRange(min=1, max=65535)])
|
||||||
|
smtp_username = StringField("Username", validators=[Optional(), Length(max=255)])
|
||||||
|
smtp_password = PasswordField("Password (leave blank to keep current)", validators=[Optional(), Length(max=255)])
|
||||||
|
smtp_encryption = SelectField("Encryption", choices=[
|
||||||
|
("none", "None (plain — not recommended)"),
|
||||||
|
("starttls", "STARTTLS (port 587)"),
|
||||||
|
("ssl", "SSL/TLS (port 465)"),
|
||||||
|
])
|
||||||
|
smtp_from_address = StringField("From address", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
smtp_from_name = StringField("From name", validators=[Optional(), Length(max=255)])
|
||||||
|
|
||||||
|
# ---- IMAP: used to save a copy of every sent message to the Sent folder ----
|
||||||
|
imap_enabled = BooleanField("Save a copy of sent mail to the mailbox (IMAP)", default=True)
|
||||||
|
imap_host = StringField("IMAP Host", validators=[Optional(), Length(max=255)])
|
||||||
|
imap_port = IntegerField("IMAP Port", validators=[Optional(), NumberRange(min=1, max=65535)])
|
||||||
|
imap_username = StringField("IMAP Username (blank = use SMTP username)", validators=[Optional(), Length(max=255)])
|
||||||
|
imap_password = PasswordField("IMAP Password (blank = keep current / use SMTP password)", validators=[Optional(), Length(max=255)])
|
||||||
|
imap_sent_folder = StringField("Sent folder name", validators=[Optional(), Length(max=128)])
|
||||||
|
|
||||||
|
submit = SubmitField("Save mail configuration")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMailForm(FlaskForm):
|
||||||
|
test_recipient = StringField("Send test email to", validators=[DataRequired(), Email()])
|
||||||
|
submit = SubmitField("Send test email")
|
||||||
|
|
||||||
|
|
||||||
|
class LogEmailForm(FlaskForm):
|
||||||
|
"""Email an exported log (CSV attachment) to a destination address."""
|
||||||
|
log_recipient = StringField("Email log to", validators=[DataRequired(), Email()])
|
||||||
|
submit = SubmitField("Email log")
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettingsForm(FlaskForm):
|
||||||
|
default_reminder_days = StringField(
|
||||||
|
"Default reminder days (comma-separated)",
|
||||||
|
validators=[DataRequired(), Regexp(r"^[\d,\s]+$")],
|
||||||
|
)
|
||||||
|
submit = SubmitField("Save settings")
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""SMTP mailer that pulls live config from the AppSetting store.
|
||||||
|
|
||||||
|
Every send is recorded in the MailLog (visible in the backoffice "Mail Log"),
|
||||||
|
and — when IMAP is configured — a copy of the outgoing message is appended to
|
||||||
|
the external mailbox's Sent folder so it shows up in normal mail clients.
|
||||||
|
"""
|
||||||
|
import smtplib
|
||||||
|
import imaplib
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr, formatdate
|
||||||
|
from flask_login import current_user
|
||||||
|
from .models import AppSetting, MailLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mail_config() -> dict:
|
||||||
|
"""Read SMTP + IMAP settings from the database."""
|
||||||
|
return {
|
||||||
|
# ---- SMTP (outgoing) ----
|
||||||
|
"host": AppSetting.get("smtp_host", "") or "",
|
||||||
|
"port": int(AppSetting.get("smtp_port", "465") or "465"),
|
||||||
|
"username": AppSetting.get("smtp_username", "") or "",
|
||||||
|
"password": AppSetting.get("smtp_password", "") or "",
|
||||||
|
"encryption": (AppSetting.get("smtp_encryption", "ssl") or "ssl").lower(),
|
||||||
|
"from_address": AppSetting.get("smtp_from_address", "") or "",
|
||||||
|
"from_name": AppSetting.get("smtp_from_name", "AlertHub") or "AlertHub",
|
||||||
|
# ---- IMAP (for saving a copy in the Sent folder) ----
|
||||||
|
"imap_enabled": (AppSetting.get("imap_enabled", "true") or "true").lower() in ("1", "true", "yes", "on"),
|
||||||
|
"imap_host": AppSetting.get("imap_host", "") or "",
|
||||||
|
"imap_port": int(AppSetting.get("imap_port", "993") or "993"),
|
||||||
|
"imap_username": AppSetting.get("imap_username", "") or "",
|
||||||
|
"imap_password": AppSetting.get("imap_password", "") or "",
|
||||||
|
"imap_sent_folder": AppSetting.get("imap_sent_folder", "Sent") or "Sent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_message(cfg, to_addresses, subject, body_text, body_html=None, attachments=None) -> EmailMessage:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = formataddr((cfg["from_name"], cfg["from_address"]))
|
||||||
|
msg["To"] = ", ".join(to_addresses)
|
||||||
|
msg["Date"] = formatdate(localtime=True)
|
||||||
|
msg.set_content(body_text)
|
||||||
|
if body_html:
|
||||||
|
msg.add_alternative(body_html, subtype="html")
|
||||||
|
for att in (attachments or []):
|
||||||
|
# att = (filename, mime_maintype, mime_subtype, bytes)
|
||||||
|
fname, maintype, subtype, data = att
|
||||||
|
msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=fname)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _append_to_sent(cfg, msg) -> tuple[bool, str]:
|
||||||
|
"""Append a copy of the message to the mailbox's Sent folder over IMAP."""
|
||||||
|
if not cfg.get("imap_enabled"):
|
||||||
|
return False, "disabled"
|
||||||
|
imap_host = cfg["imap_host"] or ""
|
||||||
|
imap_user = cfg["imap_username"] or cfg["username"]
|
||||||
|
imap_pass = cfg["imap_password"] or cfg["password"]
|
||||||
|
if not imap_host or not imap_user or not imap_pass:
|
||||||
|
return False, "IMAP not fully configured"
|
||||||
|
folder = cfg.get("imap_sent_folder") or "Sent"
|
||||||
|
try:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with imaplib.IMAP4_SSL(imap_host, cfg.get("imap_port", 993), ssl_context=context, timeout=20) as imap:
|
||||||
|
imap.login(imap_user, imap_pass)
|
||||||
|
raw = msg.as_bytes()
|
||||||
|
# \Seen so it doesn't show as unread in the Sent folder
|
||||||
|
typ, _ = imap.append(folder, "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw)
|
||||||
|
if typ != "OK":
|
||||||
|
return False, f"IMAP append returned {typ}"
|
||||||
|
return True, "saved"
|
||||||
|
except (imaplib.IMAP4.error, ssl.SSLError, OSError) as e:
|
||||||
|
logger.exception("IMAP append to Sent failed")
|
||||||
|
return False, f"IMAP error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_send(cfg, msg) -> tuple[bool, str]:
|
||||||
|
encryption = cfg["encryption"]
|
||||||
|
try:
|
||||||
|
if encryption == "ssl":
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(cfg["host"], cfg["port"], context=context, timeout=20) as server:
|
||||||
|
if cfg["username"]:
|
||||||
|
server.login(cfg["username"], cfg["password"])
|
||||||
|
server.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server:
|
||||||
|
server.ehlo()
|
||||||
|
if encryption == "starttls":
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
server.starttls(context=context)
|
||||||
|
server.ehlo()
|
||||||
|
if cfg["username"]:
|
||||||
|
server.login(cfg["username"], cfg["password"])
|
||||||
|
server.send_message(msg)
|
||||||
|
return True, "ok"
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
logger.exception("SMTP auth failed")
|
||||||
|
err = e.smtp_error.decode("utf-8", errors="ignore") if isinstance(e.smtp_error, bytes) else e.smtp_error
|
||||||
|
return False, f"Authentication failed: {e.smtp_code} {err}"
|
||||||
|
except (smtplib.SMTPException, ssl.SSLError, OSError) as e:
|
||||||
|
logger.exception("SMTP error")
|
||||||
|
return False, f"SMTP error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_mail(to_addresses, subject: str, body_text: str, body_html: str | None = None,
|
||||||
|
attachments=None, kind: str = "general", triggered_by: str | None = None) -> tuple[bool, str]:
|
||||||
|
"""Send an email. Returns (success, message). Never raises.
|
||||||
|
|
||||||
|
Side effects:
|
||||||
|
- Appends a copy to the external mailbox Sent folder (IMAP) on success.
|
||||||
|
- Records the send (and Sent-copy result) in the MailLog.
|
||||||
|
"""
|
||||||
|
cfg = get_mail_config()
|
||||||
|
|
||||||
|
# Resolve who triggered this (username) for the log.
|
||||||
|
if triggered_by is None:
|
||||||
|
try:
|
||||||
|
triggered_by = current_user.username if current_user.is_authenticated else "system"
|
||||||
|
except Exception:
|
||||||
|
triggered_by = "system"
|
||||||
|
|
||||||
|
if not cfg["host"] or not cfg["from_address"]:
|
||||||
|
info = "SMTP not configured (host or from-address missing)."
|
||||||
|
MailLog.record(kind, to_addresses, subject, "failed", info, "n/a", triggered_by)
|
||||||
|
return False, info
|
||||||
|
|
||||||
|
if isinstance(to_addresses, str):
|
||||||
|
to_addresses = [to_addresses]
|
||||||
|
to_addresses = [a for a in to_addresses if a]
|
||||||
|
if not to_addresses:
|
||||||
|
info = "No recipients."
|
||||||
|
MailLog.record(kind, "", subject, "failed", info, "n/a", triggered_by)
|
||||||
|
return False, info
|
||||||
|
|
||||||
|
msg = _build_message(cfg, to_addresses, subject, body_text, body_html, attachments)
|
||||||
|
|
||||||
|
ok, info = _smtp_send(cfg, msg)
|
||||||
|
|
||||||
|
sent_copy = "n/a"
|
||||||
|
if ok:
|
||||||
|
copied, copy_info = _append_to_sent(cfg, msg)
|
||||||
|
if copy_info == "disabled":
|
||||||
|
sent_copy = "disabled"
|
||||||
|
elif copied:
|
||||||
|
sent_copy = "saved"
|
||||||
|
else:
|
||||||
|
sent_copy = "failed"
|
||||||
|
info = f"{info}; Sent-copy: {copy_info}"
|
||||||
|
|
||||||
|
MailLog.record(kind, to_addresses, subject,
|
||||||
|
"sent" if ok else "failed", info, sent_copy, triggered_by)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
return True, f"Sent to {len(to_addresses)} recipient(s)."
|
||||||
|
return False, info
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""SQLAlchemy models."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import UserMixin
|
||||||
|
import bcrypt
|
||||||
|
import json
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.String(16), nullable=False, default="user") # 'user' | 'admin'
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
mfa_secret = db.Column(db.String(64), nullable=True)
|
||||||
|
mfa_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Optional user avatar (stored as normalised PNG bytes in the DB so it
|
||||||
|
# survives an "unzip over the top" upgrade without any external files).
|
||||||
|
avatar_data = db.Column(db.LargeBinary, nullable=True)
|
||||||
|
avatar_mime = db.Column(db.String(64), nullable=True)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||||
|
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
failed_login_count = db.Column(db.Integer, default=0, nullable=False)
|
||||||
|
locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# ---- password helpers ----
|
||||||
|
def set_password(self, password: str) -> None:
|
||||||
|
self.password_hash = bcrypt.hashpw(
|
||||||
|
password.encode("utf-8"), bcrypt.gensalt(rounds=12)
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(
|
||||||
|
password.encode("utf-8"), self.password_hash.encode("utf-8")
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self.role == "admin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locked(self) -> bool:
|
||||||
|
return self.locked_until is not None and self.locked_until > utcnow()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_avatar(self) -> bool:
|
||||||
|
return self.avatar_data is not None and len(self.avatar_data) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initials(self) -> str:
|
||||||
|
return (self.username or "?")[:2].upper()
|
||||||
|
|
||||||
|
|
||||||
|
class Alert(db.Model):
|
||||||
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
category = db.Column(db.String(64), nullable=True) # e.g. 'License', 'Renewal'
|
||||||
|
expiration_date = db.Column(db.Date, nullable=False)
|
||||||
|
# JSON-encoded list of integer days, e.g. "[30, 14, 7, 1]"
|
||||||
|
reminder_days_json = db.Column(db.String(255), nullable=False, default="[30,14,7,1]")
|
||||||
|
# JSON-encoded list of days for which the reminder has already been sent (avoid dupes)
|
||||||
|
reminders_sent_json = db.Column(db.String(255), nullable=False, default="[]")
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||||
|
|
||||||
|
creator = db.relationship("User", foreign_keys=[created_by])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reminder_days(self) -> list:
|
||||||
|
try:
|
||||||
|
return sorted(set(int(x) for x in json.loads(self.reminder_days_json)), reverse=True)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
@reminder_days.setter
|
||||||
|
def reminder_days(self, value) -> None:
|
||||||
|
self.reminder_days_json = json.dumps(sorted(set(int(x) for x in value), reverse=True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reminders_sent(self) -> list:
|
||||||
|
try:
|
||||||
|
return list(json.loads(self.reminders_sent_json))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
@reminders_sent.setter
|
||||||
|
def reminders_sent(self, value) -> None:
|
||||||
|
self.reminders_sent_json = json.dumps(list(value))
|
||||||
|
|
||||||
|
|
||||||
|
class AppSetting(db.Model):
|
||||||
|
"""Generic key/value settings store (mail config + misc)."""
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
key = db.Column(db.String(64), primary_key=True)
|
||||||
|
value = db.Column(db.Text, nullable=True)
|
||||||
|
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, key: str, default=None):
|
||||||
|
row = db.session.get(cls, key)
|
||||||
|
return row.value if row is not None else default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set(cls, key: str, value: str) -> None:
|
||||||
|
row = db.session.get(cls, key)
|
||||||
|
if row is None:
|
||||||
|
row = cls(key=key, value=value)
|
||||||
|
db.session.add(row)
|
||||||
|
else:
|
||||||
|
row.value = value
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls) -> dict:
|
||||||
|
return {row.key: row.value for row in cls.query.all()}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
timestamp = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
username = db.Column(db.String(64), nullable=True) # denormalised for survival after delete
|
||||||
|
action = db.Column(db.String(64), nullable=False)
|
||||||
|
details = db.Column(db.Text, nullable=True)
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def record(action: str, user=None, details: str = "", ip: str = ""):
|
||||||
|
entry = AuditLog(
|
||||||
|
action=action,
|
||||||
|
user_id=getattr(user, "id", None),
|
||||||
|
username=getattr(user, "username", None),
|
||||||
|
details=details[:1000] if details else None,
|
||||||
|
ip_address=ip,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class MailLog(db.Model):
|
||||||
|
"""Record of every outbound email dispatched by the platform."""
|
||||||
|
__tablename__ = "mail_log"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
timestamp = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
|
||||||
|
kind = db.Column(db.String(32), nullable=False, default="general") # reminder | test | log-export | general
|
||||||
|
recipients = db.Column(db.Text, nullable=True)
|
||||||
|
subject = db.Column(db.String(500), nullable=True)
|
||||||
|
status = db.Column(db.String(16), nullable=False, default="unknown") # sent | failed
|
||||||
|
info = db.Column(db.Text, nullable=True)
|
||||||
|
sent_copy = db.Column(db.String(24), nullable=False, default="n/a") # saved | failed | disabled | n/a
|
||||||
|
triggered_by = db.Column(db.String(64), nullable=True) # username or "system"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def record(kind, recipients, subject, status, info="", sent_copy="n/a", triggered_by="system"):
|
||||||
|
if isinstance(recipients, (list, tuple)):
|
||||||
|
recipients = ", ".join(str(r) for r in recipients)
|
||||||
|
entry = MailLog(
|
||||||
|
kind=(kind or "general")[:32],
|
||||||
|
recipients=(recipients or "")[:2000],
|
||||||
|
subject=(subject or "")[:500],
|
||||||
|
status=(status or "unknown")[:16],
|
||||||
|
info=(info or "")[:2000],
|
||||||
|
sent_copy=(sent_copy or "n/a")[:24],
|
||||||
|
triggered_by=(triggered_by or "system")[:64],
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""Authentication routes: first-run setup, login, MFA, logout, password, avatar."""
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
from datetime import timedelta
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for, flash, request, session,
|
||||||
|
current_app, abort, Response,
|
||||||
|
)
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
|
||||||
|
from .models import db, User, AuditLog, utcnow
|
||||||
|
from .forms import LoginForm, MfaForm, MfaSetupForm, ChangePasswordForm, SetupAdminForm, AvatarForm
|
||||||
|
from .auth_utils import get_remote_ip, login_required_full
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
AVATAR_SIZE = 256
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_avatar(file_storage) -> bytes:
|
||||||
|
"""Open an uploaded image, square-crop + resize, return PNG bytes.
|
||||||
|
|
||||||
|
Raises ValueError if the file is not a usable image.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = Image.open(file_storage.stream)
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
img = img.convert("RGBA") if img.mode in ("RGBA", "LA", "P") else img.convert("RGB")
|
||||||
|
# Center-crop to a square, then resize.
|
||||||
|
img = ImageOps.fit(img, (AVATAR_SIZE, AVATAR_SIZE), method=Image.LANCZOS)
|
||||||
|
if img.mode == "RGBA":
|
||||||
|
background = Image.new("RGBA", img.size, (255, 255, 255, 0))
|
||||||
|
img = Image.alpha_composite(background, img)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG", optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
except Exception as exc: # noqa: BLE001 - surface a clean error to the user
|
||||||
|
raise ValueError(f"Could not process image: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# First-run setup — create the initial administrator
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@auth_bp.route("/setup", methods=["GET", "POST"])
|
||||||
|
def setup():
|
||||||
|
# Only available while there are zero users. Once an admin exists, hide it.
|
||||||
|
if User.query.count() > 0:
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
form = SetupAdminForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
admin = User(
|
||||||
|
username=form.username.data.strip(),
|
||||||
|
email=form.email.data.strip().lower(),
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
admin.set_password(form.password.data)
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
current_app._alerthub_has_users = True
|
||||||
|
AuditLog.record("setup.admin_created", user=admin, details=f"username={admin.username}", ip=get_remote_ip())
|
||||||
|
flash("Administrator account created. Please sign in.", "success")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
return render_template("setup.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Login
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated and not session.get("mfa_pending"):
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User.query.filter_by(username=form.username.data.strip()).first()
|
||||||
|
ip = get_remote_ip()
|
||||||
|
|
||||||
|
if user is None or not user.check_password(form.password.data):
|
||||||
|
# Increment failed login if user exists
|
||||||
|
if user is not None:
|
||||||
|
user.failed_login_count = (user.failed_login_count or 0) + 1
|
||||||
|
if user.failed_login_count >= 5:
|
||||||
|
user.locked_until = utcnow() + timedelta(minutes=15)
|
||||||
|
user.failed_login_count = 0
|
||||||
|
AuditLog.record("login.lockout", user=user, details=f"5 failed attempts", ip=ip)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("login.fail", details=f"username={form.username.data}", ip=ip)
|
||||||
|
flash("Invalid credentials.", "error")
|
||||||
|
return render_template("login.html", form=form), 401
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
flash("Account is disabled.", "error")
|
||||||
|
AuditLog.record("login.disabled", user=user, ip=ip)
|
||||||
|
return render_template("login.html", form=form), 403
|
||||||
|
|
||||||
|
if user.is_locked:
|
||||||
|
flash("Account is temporarily locked due to failed login attempts. Try again later.", "error")
|
||||||
|
AuditLog.record("login.locked", user=user, ip=ip)
|
||||||
|
return render_template("login.html", form=form), 423
|
||||||
|
|
||||||
|
# Password OK — reset counters
|
||||||
|
user.failed_login_count = 0
|
||||||
|
user.locked_until = None
|
||||||
|
|
||||||
|
if user.mfa_enabled:
|
||||||
|
# Stage 1 success — but require MFA before granting full session
|
||||||
|
session["mfa_pending"] = True
|
||||||
|
session["pending_user_id"] = user.id
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("login.password_ok", user=user, ip=ip)
|
||||||
|
return redirect(url_for("auth.mfa_challenge"))
|
||||||
|
|
||||||
|
# No MFA — full login
|
||||||
|
login_user(user, remember=False)
|
||||||
|
session.permanent = True
|
||||||
|
session.pop("mfa_pending", None)
|
||||||
|
session.pop("pending_user_id", None)
|
||||||
|
user.last_login_at = utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("login.success", user=user, ip=ip)
|
||||||
|
|
||||||
|
next_url = request.args.get("next") or url_for("main.index")
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
return render_template("login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# MFA challenge (after password OK)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@auth_bp.route("/mfa", methods=["GET", "POST"])
|
||||||
|
def mfa_challenge():
|
||||||
|
if not session.get("mfa_pending"):
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
user_id = session.get("pending_user_id")
|
||||||
|
user = db.session.get(User, user_id) if user_id else None
|
||||||
|
if user is None or not user.mfa_enabled or not user.mfa_secret:
|
||||||
|
session.pop("mfa_pending", None)
|
||||||
|
session.pop("pending_user_id", None)
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
form = MfaForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
totp = pyotp.TOTP(user.mfa_secret)
|
||||||
|
if totp.verify(form.code.data, valid_window=1):
|
||||||
|
login_user(user, remember=False)
|
||||||
|
session.permanent = True
|
||||||
|
session.pop("mfa_pending", None)
|
||||||
|
session.pop("pending_user_id", None)
|
||||||
|
user.last_login_at = utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("login.mfa_ok", user=user, ip=get_remote_ip())
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
flash("Invalid authentication code.", "error")
|
||||||
|
AuditLog.record("login.mfa_fail", user=user, ip=get_remote_ip())
|
||||||
|
|
||||||
|
return render_template("mfa.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Logout
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
AuditLog.record("logout", user=current_user, ip=get_remote_ip())
|
||||||
|
logout_user()
|
||||||
|
session.clear()
|
||||||
|
flash("Signed out.", "success")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Account: change password / set up MFA
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@auth_bp.route("/account", methods=["GET"])
|
||||||
|
@login_required_full
|
||||||
|
def account():
|
||||||
|
return render_template(
|
||||||
|
"account.html",
|
||||||
|
password_form=ChangePasswordForm(),
|
||||||
|
mfa_setup_form=MfaSetupForm(),
|
||||||
|
avatar_form=AvatarForm(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Avatar: upload / remove / serve
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@auth_bp.route("/account/avatar", methods=["POST"])
|
||||||
|
@login_required_full
|
||||||
|
def avatar_upload():
|
||||||
|
form = AvatarForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
png_bytes = _normalise_avatar(form.avatar.data)
|
||||||
|
except ValueError as e:
|
||||||
|
flash(str(e), "error")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
|
current_user.avatar_data = png_bytes
|
||||||
|
current_user.avatar_mime = "image/png"
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("avatar.update", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("Avatar updated.", "success")
|
||||||
|
else:
|
||||||
|
for errs in form.errors.values():
|
||||||
|
for e in errs:
|
||||||
|
flash(e, "error")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/account/avatar/remove", methods=["POST"])
|
||||||
|
@login_required_full
|
||||||
|
def avatar_remove():
|
||||||
|
current_user.avatar_data = None
|
||||||
|
current_user.avatar_mime = None
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("avatar.remove", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("Avatar removed.", "info")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/avatar/<int:user_id>", methods=["GET"])
|
||||||
|
@login_required_full
|
||||||
|
def avatar(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None or not user.has_avatar:
|
||||||
|
abort(404)
|
||||||
|
resp = Response(user.avatar_data, mimetype=user.avatar_mime or "image/png")
|
||||||
|
resp.headers["Cache-Control"] = "private, max-age=60"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/account/password", methods=["POST"])
|
||||||
|
@login_required_full
|
||||||
|
def change_password():
|
||||||
|
form = ChangePasswordForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if not current_user.check_password(form.current_password.data):
|
||||||
|
flash("Current password is incorrect.", "error")
|
||||||
|
else:
|
||||||
|
current_user.set_password(form.new_password.data)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("password.change", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("Password updated.", "success")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
|
return render_template(
|
||||||
|
"account.html",
|
||||||
|
password_form=form,
|
||||||
|
mfa_setup_form=MfaSetupForm(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/account/mfa/setup", methods=["GET", "POST"])
|
||||||
|
@login_required_full
|
||||||
|
def mfa_setup():
|
||||||
|
"""Generate a new TOTP secret + QR code; user confirms with a 6-digit code."""
|
||||||
|
if current_user.mfa_enabled:
|
||||||
|
flash("MFA is already enabled.", "info")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
|
|
||||||
|
# Keep candidate secret in session until verified
|
||||||
|
if "mfa_setup_secret" not in session:
|
||||||
|
session["mfa_setup_secret"] = pyotp.random_base32()
|
||||||
|
|
||||||
|
secret = session["mfa_setup_secret"]
|
||||||
|
issuer = current_app.config.get("MFA_ISSUER_NAME", "AlertHub")
|
||||||
|
uri = pyotp.TOTP(secret).provisioning_uri(name=current_user.email, issuer_name=issuer)
|
||||||
|
|
||||||
|
# Generate QR code as base64 PNG
|
||||||
|
img = qrcode.make(uri)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
form = MfaSetupForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if pyotp.TOTP(secret).verify(form.code.data, valid_window=1):
|
||||||
|
current_user.mfa_secret = secret
|
||||||
|
current_user.mfa_enabled = True
|
||||||
|
db.session.commit()
|
||||||
|
session.pop("mfa_setup_secret", None)
|
||||||
|
AuditLog.record("mfa.enabled", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("MFA enabled successfully. You will be prompted for a code on next sign-in.", "success")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
|
flash("Verification code did not match. Try again.", "error")
|
||||||
|
|
||||||
|
return render_template("mfa_setup.html", form=form, qr_b64=qr_b64, secret=secret)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/account/mfa/disable", methods=["POST"])
|
||||||
|
@login_required_full
|
||||||
|
def mfa_disable():
|
||||||
|
current_user.mfa_enabled = False
|
||||||
|
current_user.mfa_secret = None
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("mfa.disabled", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("MFA disabled.", "info")
|
||||||
|
return redirect(url_for("auth.account"))
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""Backoffice routes — admins only.
|
||||||
|
Sections:
|
||||||
|
3. Manage Users & Permissions
|
||||||
|
- Mail / SMTP configuration
|
||||||
|
- Global settings (default reminder days, scheduler, run-now)
|
||||||
|
- Audit log viewer
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import date, datetime
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, current_app, Response
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from .models import db, User, Alert, AppSetting, AuditLog, MailLog
|
||||||
|
from .forms import UserForm, MailConfigForm, TestMailForm, GlobalSettingsForm, LogEmailForm
|
||||||
|
from .auth_utils import admin_required, get_remote_ip
|
||||||
|
from .mailer import send_mail, get_mail_config
|
||||||
|
from .scheduler import trigger_now
|
||||||
|
|
||||||
|
backoffice_bp = Blueprint("backoffice", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_response(filename: str, header: list, rows) -> Response:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(header)
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow(r)
|
||||||
|
data = buf.getvalue()
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_rows():
|
||||||
|
for e in AuditLog.query.order_by(AuditLog.timestamp.desc()).all():
|
||||||
|
yield [
|
||||||
|
e.timestamp.strftime("%Y-%m-%d %H:%M:%S") if e.timestamp else "",
|
||||||
|
e.username or "",
|
||||||
|
e.action or "",
|
||||||
|
e.details or "",
|
||||||
|
e.ip_address or "",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _maillog_rows():
|
||||||
|
for m in MailLog.query.order_by(MailLog.timestamp.desc()).all():
|
||||||
|
yield [
|
||||||
|
m.timestamp.strftime("%Y-%m-%d %H:%M:%S") if m.timestamp else "",
|
||||||
|
m.kind or "",
|
||||||
|
m.recipients or "",
|
||||||
|
m.subject or "",
|
||||||
|
m.status or "",
|
||||||
|
m.sent_copy or "",
|
||||||
|
m.triggered_by or "",
|
||||||
|
m.info or "",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_CSV_HEADER = ["timestamp_utc", "user", "action", "details", "ip"]
|
||||||
|
MAILLOG_CSV_HEADER = ["timestamp_utc", "kind", "recipients", "subject", "status", "sent_copy", "triggered_by", "info"]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_csv_string(header, rows) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(header)
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow(r)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Backoffice home — admin dashboard
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@backoffice_bp.route("/")
|
||||||
|
@admin_required
|
||||||
|
def dashboard():
|
||||||
|
user_count = User.query.count()
|
||||||
|
admin_count = User.query.filter_by(role="admin").count()
|
||||||
|
active_alerts = Alert.query.filter_by(is_active=True).count()
|
||||||
|
today = date.today()
|
||||||
|
expiring_soon = Alert.query.filter(
|
||||||
|
Alert.is_active.is_(True),
|
||||||
|
Alert.expiration_date >= today,
|
||||||
|
).order_by(Alert.expiration_date.asc()).limit(5).all()
|
||||||
|
recent_audit = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(10).all()
|
||||||
|
return render_template(
|
||||||
|
"backoffice/dashboard.html",
|
||||||
|
user_count=user_count,
|
||||||
|
admin_count=admin_count,
|
||||||
|
active_alerts=active_alerts,
|
||||||
|
expiring_soon=expiring_soon,
|
||||||
|
recent_audit=recent_audit,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# User management
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@backoffice_bp.route("/users")
|
||||||
|
@admin_required
|
||||||
|
def users_list():
|
||||||
|
users = User.query.order_by(User.username.asc()).all()
|
||||||
|
return render_template("backoffice/users_list.html", users=users)
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/users/new", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def user_new():
|
||||||
|
form = UserForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if not form.password.data:
|
||||||
|
flash("Password is required for a new user.", "error")
|
||||||
|
elif User.query.filter((User.username == form.username.data) | (User.email == form.email.data)).first():
|
||||||
|
flash("Username or email already exists.", "error")
|
||||||
|
elif len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]:
|
||||||
|
flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error")
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
username=form.username.data.strip(),
|
||||||
|
email=form.email.data.strip().lower(),
|
||||||
|
role=form.role.data,
|
||||||
|
is_active=form.is_active.data,
|
||||||
|
)
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("user.create", user=current_user, details=f"new={user.username} role={user.role}", ip=get_remote_ip())
|
||||||
|
flash(f"User '{user.username}' created.", "success")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
return render_template("backoffice/user_form.html", form=form, user=None)
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def user_edit(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
abort(404)
|
||||||
|
form = UserForm(obj=user)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
# Username / email uniqueness check (excluding self)
|
||||||
|
clash = User.query.filter(
|
||||||
|
User.id != user.id,
|
||||||
|
(User.username == form.username.data) | (User.email == form.email.data.lower())
|
||||||
|
).first()
|
||||||
|
if clash:
|
||||||
|
flash("Another user already has that username or email.", "error")
|
||||||
|
else:
|
||||||
|
# Prevent self-demotion of last admin / self-deactivation lock-out
|
||||||
|
if user.id == current_user.id:
|
||||||
|
if form.role.data != "admin":
|
||||||
|
flash("You cannot demote yourself.", "error")
|
||||||
|
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||||
|
if not form.is_active.data:
|
||||||
|
flash("You cannot deactivate your own account.", "error")
|
||||||
|
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||||
|
|
||||||
|
# Prevent removing the last active admin
|
||||||
|
if user.role == "admin" and form.role.data != "admin":
|
||||||
|
other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count()
|
||||||
|
if other_admins == 0:
|
||||||
|
flash("Cannot demote the last administrator.", "error")
|
||||||
|
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||||
|
|
||||||
|
user.username = form.username.data.strip()
|
||||||
|
user.email = form.email.data.strip().lower()
|
||||||
|
user.role = form.role.data
|
||||||
|
user.is_active = form.is_active.data
|
||||||
|
if form.password.data:
|
||||||
|
if len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]:
|
||||||
|
flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error")
|
||||||
|
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("user.update", user=current_user, details=f"id={user.id} username={user.username}", ip=get_remote_ip())
|
||||||
|
flash("User updated.", "success")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/users/<int:user_id>/delete", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def user_delete(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
abort(404)
|
||||||
|
if user.id == current_user.id:
|
||||||
|
flash("You cannot delete your own account.", "error")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
if user.role == "admin":
|
||||||
|
other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count()
|
||||||
|
if other_admins == 0:
|
||||||
|
flash("Cannot delete the last administrator.", "error")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
username = user.username
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("user.delete", user=current_user, details=f"deleted={username}", ip=get_remote_ip())
|
||||||
|
flash(f"User '{username}' deleted.", "success")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/users/<int:user_id>/reset-mfa", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def user_reset_mfa(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
abort(404)
|
||||||
|
user.mfa_enabled = False
|
||||||
|
user.mfa_secret = None
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("user.mfa_reset", user=current_user, details=f"target={user.username}", ip=get_remote_ip())
|
||||||
|
flash(f"MFA reset for '{user.username}'. They can re-enrol on next login.", "info")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/users/<int:user_id>/unlock", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def user_unlock(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
abort(404)
|
||||||
|
user.locked_until = None
|
||||||
|
user.failed_login_count = 0
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("user.unlock", user=current_user, details=f"target={user.username}", ip=get_remote_ip())
|
||||||
|
flash(f"Unlocked '{user.username}'.", "success")
|
||||||
|
return redirect(url_for("backoffice.users_list"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mail configuration
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@backoffice_bp.route("/mail-config", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def mail_config():
|
||||||
|
form = MailConfigForm()
|
||||||
|
test_form = TestMailForm()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
cfg = get_mail_config()
|
||||||
|
form.smtp_host.data = cfg["host"]
|
||||||
|
form.smtp_port.data = cfg["port"]
|
||||||
|
form.smtp_username.data = cfg["username"]
|
||||||
|
form.smtp_encryption.data = cfg["encryption"]
|
||||||
|
form.smtp_from_address.data = cfg["from_address"]
|
||||||
|
form.smtp_from_name.data = cfg["from_name"]
|
||||||
|
form.imap_enabled.data = cfg["imap_enabled"]
|
||||||
|
form.imap_host.data = cfg["imap_host"]
|
||||||
|
form.imap_port.data = cfg["imap_port"]
|
||||||
|
form.imap_username.data = cfg["imap_username"]
|
||||||
|
form.imap_sent_folder.data = cfg["imap_sent_folder"]
|
||||||
|
|
||||||
|
if form.submit.data and form.validate_on_submit():
|
||||||
|
AppSetting.set("smtp_host", form.smtp_host.data.strip())
|
||||||
|
AppSetting.set("smtp_port", str(form.smtp_port.data))
|
||||||
|
AppSetting.set("smtp_username", form.smtp_username.data.strip())
|
||||||
|
if form.smtp_password.data:
|
||||||
|
AppSetting.set("smtp_password", form.smtp_password.data)
|
||||||
|
AppSetting.set("smtp_encryption", form.smtp_encryption.data)
|
||||||
|
AppSetting.set("smtp_from_address", form.smtp_from_address.data.strip().lower())
|
||||||
|
AppSetting.set("smtp_from_name", (form.smtp_from_name.data or "").strip() or "AlertHub")
|
||||||
|
# ---- IMAP (Sent-folder copy) ----
|
||||||
|
AppSetting.set("imap_enabled", "true" if form.imap_enabled.data else "false")
|
||||||
|
AppSetting.set("imap_host", (form.imap_host.data or "").strip())
|
||||||
|
AppSetting.set("imap_port", str(form.imap_port.data or 993))
|
||||||
|
AppSetting.set("imap_username", (form.imap_username.data or "").strip())
|
||||||
|
if form.imap_password.data:
|
||||||
|
AppSetting.set("imap_password", form.imap_password.data)
|
||||||
|
AppSetting.set("imap_sent_folder", (form.imap_sent_folder.data or "").strip() or "Sent")
|
||||||
|
AuditLog.record("mailconfig.update", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("Mail configuration saved.", "success")
|
||||||
|
return redirect(url_for("backoffice.mail_config"))
|
||||||
|
|
||||||
|
return render_template("backoffice/mail_config.html", form=form, test_form=test_form)
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/mail-config/test", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def mail_test():
|
||||||
|
test_form = TestMailForm()
|
||||||
|
if test_form.validate_on_submit():
|
||||||
|
ok, info = send_mail(
|
||||||
|
test_form.test_recipient.data.strip(),
|
||||||
|
"[AlertHub] Test email",
|
||||||
|
"This is a test email from AlertHub. If you received this, SMTP is configured correctly.",
|
||||||
|
"<p>This is a <strong>test email</strong> from AlertHub. If you received this, SMTP is configured correctly.</p>",
|
||||||
|
kind="test",
|
||||||
|
triggered_by=current_user.username,
|
||||||
|
)
|
||||||
|
AuditLog.record("mailconfig.test", user=current_user, details=f"to={test_form.test_recipient.data} ok={ok}", ip=get_remote_ip())
|
||||||
|
flash(("Test email sent. " if ok else "Test failed: ") + info, "success" if ok else "error")
|
||||||
|
else:
|
||||||
|
flash("Provide a valid recipient email address.", "error")
|
||||||
|
return redirect(url_for("backoffice.mail_config"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Global settings + scheduler trigger
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@backoffice_bp.route("/settings", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def settings():
|
||||||
|
form = GlobalSettingsForm()
|
||||||
|
if request.method == "GET":
|
||||||
|
form.default_reminder_days.data = AppSetting.get("default_reminder_days", "30,14,7,1")
|
||||||
|
if form.validate_on_submit():
|
||||||
|
cleaned = ",".join(
|
||||||
|
str(x) for x in sorted({int(p.strip()) for p in form.default_reminder_days.data.split(",") if p.strip().isdigit()}, reverse=True)
|
||||||
|
)
|
||||||
|
if not cleaned:
|
||||||
|
flash("Please enter at least one valid day value.", "error")
|
||||||
|
else:
|
||||||
|
AppSetting.set("default_reminder_days", cleaned)
|
||||||
|
AuditLog.record("settings.update", user=current_user, details=f"default_reminder_days={cleaned}", ip=get_remote_ip())
|
||||||
|
flash("Settings saved.", "success")
|
||||||
|
return redirect(url_for("backoffice.settings"))
|
||||||
|
scheduler_interval = current_app.config.get("SCHEDULER_INTERVAL_MINUTES", 60)
|
||||||
|
return render_template("backoffice/settings.html", form=form, scheduler_interval=scheduler_interval)
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/settings/run-check", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def run_check_now():
|
||||||
|
"""Manually run the reminder scan once."""
|
||||||
|
try:
|
||||||
|
trigger_now(current_app._get_current_object())
|
||||||
|
AuditLog.record("scheduler.manual_run", user=current_user, ip=get_remote_ip())
|
||||||
|
flash("Reminder check executed.", "success")
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.exception("Manual scheduler run failed")
|
||||||
|
flash(f"Reminder check failed: {e}", "error")
|
||||||
|
return redirect(url_for("backoffice.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Audit log viewer
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@backoffice_bp.route("/audit-log")
|
||||||
|
@admin_required
|
||||||
|
def audit_log():
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = 50
|
||||||
|
pagination = AuditLog.query.order_by(AuditLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
return render_template("backoffice/audit_log.html", pagination=pagination, log_email_form=LogEmailForm())
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/audit-log/export")
|
||||||
|
@admin_required
|
||||||
|
def audit_log_export():
|
||||||
|
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||||
|
AuditLog.record("auditlog.export", user=current_user, ip=get_remote_ip())
|
||||||
|
return _csv_response(f"alerthub-audit-log-{stamp}.csv", AUDIT_CSV_HEADER, _audit_rows())
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/audit-log/email", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def audit_log_email():
|
||||||
|
form = LogEmailForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Provide a valid destination email address.", "error")
|
||||||
|
return redirect(url_for("backoffice.audit_log"))
|
||||||
|
recipient = form.log_recipient.data.strip()
|
||||||
|
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||||
|
csv_str = _build_csv_string(AUDIT_CSV_HEADER, _audit_rows())
|
||||||
|
ok, info = send_mail(
|
||||||
|
recipient,
|
||||||
|
f"[AlertHub] Audit log export ({stamp} UTC)",
|
||||||
|
"Attached is the AlertHub audit log export requested from the backoffice.",
|
||||||
|
"<p>Attached is the <strong>AlertHub audit log</strong> export requested from the backoffice.</p>",
|
||||||
|
attachments=[(f"alerthub-audit-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))],
|
||||||
|
kind="log-export",
|
||||||
|
triggered_by=current_user.username,
|
||||||
|
)
|
||||||
|
AuditLog.record("auditlog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip())
|
||||||
|
flash(("Audit log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error")
|
||||||
|
return redirect(url_for("backoffice.audit_log"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mail log viewer (every message the platform sends)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@backoffice_bp.route("/mail-log")
|
||||||
|
@admin_required
|
||||||
|
def mail_log():
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = 50
|
||||||
|
pagination = MailLog.query.order_by(MailLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
return render_template("backoffice/mail_log.html", pagination=pagination, log_email_form=LogEmailForm())
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/mail-log/export")
|
||||||
|
@admin_required
|
||||||
|
def mail_log_export():
|
||||||
|
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||||
|
AuditLog.record("maillog.export", user=current_user, ip=get_remote_ip())
|
||||||
|
return _csv_response(f"alerthub-mail-log-{stamp}.csv", MAILLOG_CSV_HEADER, _maillog_rows())
|
||||||
|
|
||||||
|
|
||||||
|
@backoffice_bp.route("/mail-log/email", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def mail_log_email():
|
||||||
|
form = LogEmailForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Provide a valid destination email address.", "error")
|
||||||
|
return redirect(url_for("backoffice.mail_log"))
|
||||||
|
recipient = form.log_recipient.data.strip()
|
||||||
|
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||||
|
csv_str = _build_csv_string(MAILLOG_CSV_HEADER, _maillog_rows())
|
||||||
|
ok, info = send_mail(
|
||||||
|
recipient,
|
||||||
|
f"[AlertHub] Mail log export ({stamp} UTC)",
|
||||||
|
"Attached is the AlertHub mail log export requested from the backoffice.",
|
||||||
|
"<p>Attached is the <strong>AlertHub mail log</strong> export requested from the backoffice.</p>",
|
||||||
|
attachments=[(f"alerthub-mail-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))],
|
||||||
|
kind="log-export",
|
||||||
|
triggered_by=current_user.username,
|
||||||
|
)
|
||||||
|
AuditLog.record("maillog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip())
|
||||||
|
flash(("Mail log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error")
|
||||||
|
return redirect(url_for("backoffice.mail_log"))
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Frontoffice routes — visible to all authenticated users.
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
1. View Data (everyone)
|
||||||
|
2. Add/Remove/Edit (admins only)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from .models import db, Alert, AppSetting, AuditLog
|
||||||
|
from .forms import AlertForm
|
||||||
|
from .auth_utils import admin_required, login_required_full, get_remote_ip
|
||||||
|
|
||||||
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_days(text: str) -> list[int]:
|
||||||
|
return sorted({int(x.strip()) for x in text.split(",") if x.strip().isdigit() and int(x.strip()) >= 0}, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Section 1 — View Data
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@main_bp.route("/")
|
||||||
|
@login_required_full
|
||||||
|
def index():
|
||||||
|
today = date.today()
|
||||||
|
alerts = Alert.query.filter_by(is_active=True).order_by(Alert.expiration_date.asc()).all()
|
||||||
|
payload = []
|
||||||
|
for a in alerts:
|
||||||
|
days_left = (a.expiration_date - today).days
|
||||||
|
payload.append({
|
||||||
|
"id": a.id,
|
||||||
|
"title": a.title,
|
||||||
|
"category": a.category,
|
||||||
|
"description": a.description,
|
||||||
|
"expiration_date": a.expiration_date.isoformat(),
|
||||||
|
"days_left": days_left,
|
||||||
|
"reminder_days": a.reminder_days,
|
||||||
|
})
|
||||||
|
return render_template("index.html", alerts=payload, today_iso=today.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Section 2 — Add / Remove / Edit
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@main_bp.route("/alerts")
|
||||||
|
@login_required_full
|
||||||
|
def alerts_list():
|
||||||
|
alerts = Alert.query.order_by(Alert.expiration_date.asc()).all()
|
||||||
|
return render_template("alerts_list.html", alerts=alerts)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/alerts/new", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def alert_new():
|
||||||
|
default_reminder_days = AppSetting.get("default_reminder_days", "30,14,7,1")
|
||||||
|
form = AlertForm(reminder_days=default_reminder_days)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
days = _parse_days(form.reminder_days.data)
|
||||||
|
if not days:
|
||||||
|
flash("Reminder days must contain at least one valid number.", "error")
|
||||||
|
else:
|
||||||
|
alert = Alert(
|
||||||
|
title=form.title.data.strip(),
|
||||||
|
category=(form.category.data or "").strip() or None,
|
||||||
|
description=(form.description.data or "").strip() or None,
|
||||||
|
expiration_date=form.expiration_date.data,
|
||||||
|
is_active=form.is_active.data,
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
alert.reminder_days = days
|
||||||
|
db.session.add(alert)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("alert.create", user=current_user, details=f"id={alert.id} title={alert.title}", ip=get_remote_ip())
|
||||||
|
flash("Alert created.", "success")
|
||||||
|
return redirect(url_for("main.alerts_list"))
|
||||||
|
return render_template("alert_form.html", form=form, alert=None)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/alerts/<int:alert_id>/edit", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def alert_edit(alert_id: int):
|
||||||
|
alert = db.session.get(Alert, alert_id)
|
||||||
|
if alert is None:
|
||||||
|
abort(404)
|
||||||
|
form = AlertForm(obj=alert)
|
||||||
|
if request.method == "GET":
|
||||||
|
form.reminder_days.data = ",".join(str(d) for d in alert.reminder_days)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
days = _parse_days(form.reminder_days.data)
|
||||||
|
if not days:
|
||||||
|
flash("Reminder days must contain at least one valid number.", "error")
|
||||||
|
else:
|
||||||
|
alert.title = form.title.data.strip()
|
||||||
|
alert.category = (form.category.data or "").strip() or None
|
||||||
|
alert.description = (form.description.data or "").strip() or None
|
||||||
|
alert.expiration_date = form.expiration_date.data
|
||||||
|
alert.is_active = form.is_active.data
|
||||||
|
# If reminder_days changed OR expiration_date changed, reset sent log
|
||||||
|
if days != alert.reminder_days:
|
||||||
|
alert.reminders_sent = []
|
||||||
|
alert.reminder_days = days
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("alert.update", user=current_user, details=f"id={alert.id}", ip=get_remote_ip())
|
||||||
|
flash("Alert updated.", "success")
|
||||||
|
return redirect(url_for("main.alerts_list"))
|
||||||
|
return render_template("alert_form.html", form=form, alert=alert)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/alerts/<int:alert_id>/delete", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def alert_delete(alert_id: int):
|
||||||
|
alert = db.session.get(Alert, alert_id)
|
||||||
|
if alert is None:
|
||||||
|
abort(404)
|
||||||
|
title = alert.title
|
||||||
|
db.session.delete(alert)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("alert.delete", user=current_user, details=f"id={alert_id} title={title}", ip=get_remote_ip())
|
||||||
|
flash(f"Alert '{title}' deleted.", "success")
|
||||||
|
return redirect(url_for("main.alerts_list"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/alerts/<int:alert_id>/reset-reminders", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def alert_reset_reminders(alert_id: int):
|
||||||
|
alert = db.session.get(Alert, alert_id)
|
||||||
|
if alert is None:
|
||||||
|
abort(404)
|
||||||
|
alert.reminders_sent = []
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.record("alert.reset_reminders", user=current_user, details=f"id={alert_id}", ip=get_remote_ip())
|
||||||
|
flash("Reminder send-history cleared. Pending reminders will be re-sent on next scheduler run.", "info")
|
||||||
|
return redirect(url_for("main.alerts_list"))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Background scheduler that scans alerts and dispatches reminder emails."""
|
||||||
|
import logging
|
||||||
|
from datetime import date, timezone
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from .models import Alert, User, db
|
||||||
|
from .mailer import send_mail
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_scheduler: BackgroundScheduler | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_and_send(app):
|
||||||
|
"""Iterate active alerts; for each reminder threshold reached, email all users."""
|
||||||
|
with app.app_context():
|
||||||
|
today = date.today()
|
||||||
|
active_alerts = Alert.query.filter_by(is_active=True).all()
|
||||||
|
recipients = [u.email for u in User.query.filter_by(is_active=True).all() if u.email]
|
||||||
|
if not recipients:
|
||||||
|
logger.info("Scheduler tick: no recipients.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for alert in active_alerts:
|
||||||
|
days_left = (alert.expiration_date - today).days
|
||||||
|
already_sent = set(alert.reminders_sent)
|
||||||
|
newly_sent = []
|
||||||
|
for threshold in alert.reminder_days:
|
||||||
|
if days_left <= threshold and threshold not in already_sent:
|
||||||
|
subject = f"[AlertHub] Reminder: {alert.title} expires in {days_left} day(s)"
|
||||||
|
body_text = (
|
||||||
|
f"Alert: {alert.title}\n"
|
||||||
|
f"Category: {alert.category or '—'}\n"
|
||||||
|
f"Expiration date: {alert.expiration_date.isoformat()}\n"
|
||||||
|
f"Days remaining: {days_left}\n\n"
|
||||||
|
f"{alert.description or ''}\n"
|
||||||
|
)
|
||||||
|
body_html = f"""
|
||||||
|
<div style="font-family: -apple-system, sans-serif; max-width: 600px;">
|
||||||
|
<h2 style="color: #00d4aa;">⏰ AlertHub Reminder</h2>
|
||||||
|
<h3>{alert.title}</h3>
|
||||||
|
<p><strong>Category:</strong> {alert.category or '—'}<br>
|
||||||
|
<strong>Expiration date:</strong> {alert.expiration_date.isoformat()}<br>
|
||||||
|
<strong>Days remaining:</strong> <span style="color:#ff6b6b;font-size:1.2em;">{days_left}</span></p>
|
||||||
|
<p>{alert.description or ''}</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
ok, info = send_mail(recipients, subject, body_text, body_html,
|
||||||
|
kind="reminder", triggered_by="system")
|
||||||
|
logger.info("Alert %s threshold %d: send=%s (%s)", alert.id, threshold, ok, info)
|
||||||
|
if ok:
|
||||||
|
newly_sent.append(threshold)
|
||||||
|
if newly_sent:
|
||||||
|
alert.reminders_sent = list(already_sent.union(newly_sent))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def init_scheduler(app):
|
||||||
|
"""Start the scheduler if not already running. Called once at app startup."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None and _scheduler.running:
|
||||||
|
return _scheduler
|
||||||
|
interval = max(1, int(app.config.get("SCHEDULER_INTERVAL_MINUTES", 60)))
|
||||||
|
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
|
_scheduler.add_job(
|
||||||
|
_check_and_send,
|
||||||
|
"interval",
|
||||||
|
minutes=interval,
|
||||||
|
args=[app],
|
||||||
|
id="alert_reminder_job",
|
||||||
|
replace_existing=True,
|
||||||
|
next_run_time=None, # don't run immediately on boot
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
logger.info("Scheduler started; reminder check every %d minute(s).", interval)
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_now(app):
|
||||||
|
"""Manual trigger (for the 'Run check now' button)."""
|
||||||
|
_check_and_send(app)
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
/* ======================================================================
|
||||||
|
AlertHub — IT operations console aesthetic
|
||||||
|
Typography: JetBrains Mono (display/mono), Space Grotesk avoided in favour
|
||||||
|
of system stack with mono accents to keep the terminal vibe.
|
||||||
|
====================================================================== */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
--font-sans: 'IBM Plex Sans', -apple-system, system-ui, sans-serif;
|
||||||
|
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
|
||||||
|
--tx-h: 1.15;
|
||||||
|
--tx-b: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dark theme (default) ---------- */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-base: #0a0e14;
|
||||||
|
--bg-elev-1: #0f141b;
|
||||||
|
--bg-elev-2: #141a23;
|
||||||
|
--bg-elev-3: #1a212c;
|
||||||
|
--grid-line: rgba(0, 212, 170, 0.04);
|
||||||
|
--border: #1f2733;
|
||||||
|
--border-strong: #2a3442;
|
||||||
|
|
||||||
|
--text-primary: #d8e2ec;
|
||||||
|
--text-secondary: #8b96a5;
|
||||||
|
--text-dim: #5a6371;
|
||||||
|
|
||||||
|
--accent: #00d4aa; /* signal green */
|
||||||
|
--accent-soft: rgba(0, 212, 170, 0.12);
|
||||||
|
--accent-hot: #00ffcc;
|
||||||
|
--magenta: #ff3e8b; /* alert magenta */
|
||||||
|
--amber: #ffb454;
|
||||||
|
--danger: #ff5c5c;
|
||||||
|
--info: #4ad8ff;
|
||||||
|
|
||||||
|
--shadow-1: 0 1px 0 rgba(255,255,255,0.03), 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
--glow-accent: 0 0 0 1px rgba(0,212,170,0.3), 0 0 20px rgba(0,212,170,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Light theme ---------- */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-base: #f4f6f9;
|
||||||
|
--bg-elev-1: #ffffff;
|
||||||
|
--bg-elev-2: #f9fbfd;
|
||||||
|
--bg-elev-3: #f0f3f7;
|
||||||
|
--grid-line: rgba(0, 130, 100, 0.05);
|
||||||
|
--border: #e2e8ef;
|
||||||
|
--border-strong: #c8d2dd;
|
||||||
|
|
||||||
|
--text-primary: #0e1620;
|
||||||
|
--text-secondary: #4a5566;
|
||||||
|
--text-dim: #8693a3;
|
||||||
|
|
||||||
|
--accent: #008f74;
|
||||||
|
--accent-soft: rgba(0, 143, 116, 0.08);
|
||||||
|
--accent-hot: #00b388;
|
||||||
|
--magenta: #d92671;
|
||||||
|
--amber: #d97a13;
|
||||||
|
--danger: #d93939;
|
||||||
|
--info: #1f9ec5;
|
||||||
|
|
||||||
|
--shadow-1: 0 1px 2px rgba(15, 30, 45, 0.06), 0 8px 20px rgba(15, 30, 45, 0.05);
|
||||||
|
--glow-accent: 0 0 0 1px rgba(0, 143, 116, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: var(--tx-b);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative grid background */
|
||||||
|
.grid-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
mask-image: radial-gradient(ellipse at top, black 30%, transparent 75%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse at top, black 30%, transparent 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-hot); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: var(--tx-h);
|
||||||
|
margin: 0 0 .6em;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.85rem; }
|
||||||
|
h2 { font-size: 1.35rem; }
|
||||||
|
h3 { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
code, .mono { font-family: var(--font-mono); font-size: .92em; }
|
||||||
|
|
||||||
|
/* ---------- Top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
backdrop-filter: blur(10px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||||
|
background: color-mix(in srgb, var(--bg-elev-1) 82%, transparent);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.topbar-inner {
|
||||||
|
max-width: 1320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.brand:hover { text-decoration: none; }
|
||||||
|
.brand-mark {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1em;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
filter: drop-shadow(0 0 6px var(--accent));
|
||||||
|
}
|
||||||
|
.brand-name { font-size: 1.05rem; }
|
||||||
|
.brand-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
body.is-backoffice .brand-tag {
|
||||||
|
background: color-mix(in srgb, var(--magenta) 14%, transparent);
|
||||||
|
color: var(--magenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
.navlink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
.navlink:hover { color: var(--text-primary); background: var(--bg-elev-2); text-decoration: none; }
|
||||||
|
.navlink.active { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.navlink.active .dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
.dot-admin { background: var(--magenta); box-shadow: 0 0 8px var(--magenta); }
|
||||||
|
.navlink-cross { border: 1px solid var(--border); }
|
||||||
|
.navlink-cross:hover { border-color: var(--border-strong); color: var(--magenta); }
|
||||||
|
.navlink-cross.active { color: var(--magenta); background: color-mix(in srgb, var(--magenta) 12%, transparent); }
|
||||||
|
.navlink-cross.active .dot-admin { background: var(--magenta); }
|
||||||
|
|
||||||
|
.topbar-right { display: inline-flex; gap: 10px; align-items: center; justify-self: end; }
|
||||||
|
.icon-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-elev-2); }
|
||||||
|
.icon-sun { display: none; }
|
||||||
|
.icon-moon { display: block; }
|
||||||
|
[data-theme="light"] .icon-sun { display: block; }
|
||||||
|
[data-theme="light"] .icon-moon { display: none; }
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 12px 4px 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
.user-chip:hover { border-color: var(--border-strong); text-decoration: none; }
|
||||||
|
.user-avatar {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--info));
|
||||||
|
color: #061218;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.user-avatar-img {
|
||||||
|
object-fit: cover;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg-elev-3);
|
||||||
|
}
|
||||||
|
.user-meta { display: flex; flex-direction: column; line-height: 1.1; }
|
||||||
|
.user-name { font-size: .85rem; }
|
||||||
|
.user-role {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.user-role.role-admin { color: var(--magenta); }
|
||||||
|
.user-role.role-user { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
|
||||||
|
/* ---------- Container ---------- */
|
||||||
|
.container {
|
||||||
|
max-width: 1320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover { text-decoration: none; }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #02181a;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--accent-hot); box-shadow: var(--glow-accent); color: #02181a; }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { border-color: var(--border-strong); background: var(--bg-elev-2); }
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 35%, transparent);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
|
||||||
|
.btn-sm { padding: 5px 10px; font-size: .78rem; }
|
||||||
|
.btn-block { display: flex; width: 100%; justify-content: center; }
|
||||||
|
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ---------- Cards / Panels ---------- */
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.panel-header h2, .panel-header h3 { margin: 0; }
|
||||||
|
.panel-body { padding: 20px; }
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.section-head .lead { color: var(--text-secondary); margin: 6px 0 0; font-size: .95rem; }
|
||||||
|
.section-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .65rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Forms ---------- */
|
||||||
|
.form { display: grid; gap: 16px; }
|
||||||
|
.form label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.form input[type=text],
|
||||||
|
.form input[type=email],
|
||||||
|
.form input[type=password],
|
||||||
|
.form input[type=number],
|
||||||
|
.form input[type=date],
|
||||||
|
.form select,
|
||||||
|
.form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: .95rem;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
.form textarea { font-family: var(--font-sans); resize: vertical; min-height: 90px; }
|
||||||
|
.form input:focus, .form select:focus, .form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
}
|
||||||
|
.form .check-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.form .check-row label {
|
||||||
|
margin: 0;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.help { color: var(--text-dim); font-size: .82rem; margin-top: 4px; }
|
||||||
|
.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
@media (max-width: 720px) { .form-grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.error { color: var(--danger); font-size: .82rem; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ---------- Flash messages ---------- */
|
||||||
|
.flash-stack { display: grid; gap: 10px; margin-bottom: 24px; }
|
||||||
|
.flash {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
.flash-icon {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.flash-success { border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||||
|
.flash-success .flash-icon { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||||
|
.flash-error { border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
|
||||||
|
.flash-error .flash-icon { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
|
||||||
|
.flash-info { border-color: color-mix(in srgb, var(--info) 40%, transparent); }
|
||||||
|
.flash-info .flash-icon { background: var(--info); }
|
||||||
|
|
||||||
|
/* ---------- Auth pages ---------- */
|
||||||
|
.auth-shell {
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.auth-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), transparent 40%, var(--magenta));
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
opacity: .5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.auth-prompt {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.auth-prompt::before { content: '> '; opacity: .7; }
|
||||||
|
.auth-card h1 { margin-bottom: 6px; font-size: 1.5rem; }
|
||||||
|
.auth-sub { color: var(--text-secondary); font-size: .9rem; margin-bottom: 24px; }
|
||||||
|
|
||||||
|
/* ---------- ALERT cards (the heart of the View page) ---------- */
|
||||||
|
.alerts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.alert-card {
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
|
||||||
|
}
|
||||||
|
.alert-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-1); border-color: var(--border-strong); }
|
||||||
|
.alert-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
.alert-card.is-warning::before { background: var(--amber); }
|
||||||
|
.alert-card.is-critical::before { background: var(--danger); }
|
||||||
|
.alert-card.is-expired::before { background: var(--text-dim); }
|
||||||
|
|
||||||
|
.alert-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.alert-title { margin: 0; font-size: 1.1rem; font-weight: 700; line-height: 1.3; }
|
||||||
|
.alert-cat {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .65rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-elev-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.alert-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.alert-message {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||||
|
}
|
||||||
|
.alert-card.is-warning .alert-message { border-left-color: var(--amber); }
|
||||||
|
.alert-card.is-critical .alert-message { border-left-color: var(--danger); }
|
||||||
|
.alert-card.is-expired .alert-message { border-left-color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* The countdown timer */
|
||||||
|
.countdown {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.countdown-segment {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 4px 8px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.countdown-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
display: block;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.alert-card.is-warning .countdown-value { color: var(--amber); }
|
||||||
|
.alert-card.is-critical .countdown-value {
|
||||||
|
color: var(--danger);
|
||||||
|
animation: pulse-num 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.alert-card.is-expired .countdown-value { color: var(--text-dim); }
|
||||||
|
.countdown-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .55rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
@keyframes pulse-num {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: .55; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-meta {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.alert-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
margin-right: 6px;
|
||||||
|
box-shadow: 0 0 6px var(--accent);
|
||||||
|
}
|
||||||
|
.alert-card.is-warning .alert-status-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
||||||
|
.alert-card.is-critical .alert-status-dot {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 8px var(--danger);
|
||||||
|
animation: blink 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.alert-card.is-expired .alert-status-dot { background: var(--text-dim); box-shadow: none; }
|
||||||
|
@keyframes blink { 50% { opacity: .25; } }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.empty .empty-icon { font-size: 2rem; opacity: .5; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* ---------- Tables ---------- */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.table th, .table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.table thead th {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.table tbody tr:hover { background: var(--bg-elev-2); }
|
||||||
|
.table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.table .actions { text-align: right; white-space: nowrap; }
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .68rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.role-badge.role-admin { background: color-mix(in srgb, var(--magenta) 14%, transparent); color: var(--magenta); }
|
||||||
|
.role-badge.role-user { background: var(--bg-elev-3); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status-badge .led { width: 6px; height: 6px; border-radius: 50%; }
|
||||||
|
.status-active .led { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||||||
|
.status-inactive .led { background: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ---------- Stat cards ---------- */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: var(--bg-elev-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 18px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.stat-accent .stat-value { color: var(--accent); }
|
||||||
|
.stat-warn .stat-value { color: var(--amber); }
|
||||||
|
|
||||||
|
/* ---------- QR / MFA pages ---------- */
|
||||||
|
.mfa-qr {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mfa-secret {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .9rem;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px;
|
||||||
|
user-select: all;
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Footer ---------- */
|
||||||
|
.footer {
|
||||||
|
max-width: 1320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 18px 24px 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* ---------- Avatar panel (account page) ---------- */
|
||||||
|
.avatar-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.avatar-preview {
|
||||||
|
width: 96px; height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg-elev-3);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.avatar-initials {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.avatar-actions { flex: 1 1 260px; min-width: 260px; }
|
||||||
|
|
||||||
|
/* ---------- Log export / email actions ---------- */
|
||||||
|
.log-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.log-email-form {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.log-email-form input[type="text"],
|
||||||
|
.log-email-form input[type="email"] { margin: 0; min-width: 220px; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.log-actions { width: 100%; }
|
||||||
|
.log-email-form { width: 100%; }
|
||||||
|
.log-email-form input { flex: 1; min-width: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-sep { opacity: .5; }
|
||||||
|
.footer-copy {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.footer .status-pill { margin-left: 18px; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.footer { flex-wrap: wrap; }
|
||||||
|
.footer-copy { margin-left: 0; flex-basis: 100%; order: 3; }
|
||||||
|
.footer .status-pill { margin-left: auto; }
|
||||||
|
}
|
||||||
|
.kbd {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.status-pill {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status-pill .led {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent);
|
||||||
|
animation: led-pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes led-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: .4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Pagination ---------- */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pagination a, .pagination span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: .82rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.pagination a:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; }
|
||||||
|
.pagination .current { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- Misc ---------- */
|
||||||
|
.row-spread { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.row-gap { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.muted { color: var(--text-secondary); }
|
||||||
|
.dim { color: var(--text-dim); }
|
||||||
|
hr { border: 0; border-top: 1px dashed var(--border); margin: 24px 0; }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.topbar-inner { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.topbar-nav { justify-self: flex-start; flex-wrap: wrap; }
|
||||||
|
.topbar-right { justify-self: flex-start; }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a0e14"/><path d="M9 9 h3 v14 h-3z M14 9 h3 v14 h-3z" fill="#00d4aa"/><circle cx="22" cy="11" r="2" fill="#ff3e8b"/></svg>
|
||||||
|
After Width: | Height: | Size: 226 B |
@@ -0,0 +1,73 @@
|
|||||||
|
// ====== Theme toggle =======================================================
|
||||||
|
(function () {
|
||||||
|
const btn = document.getElementById('theme-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||||
|
root.setAttribute('data-theme', next);
|
||||||
|
try { localStorage.setItem('alerthub-theme', next); } catch (_) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ====== Countdown timer ====================================================
|
||||||
|
// Each .countdown element has data-expires="YYYY-MM-DD" and renders 4 cells
|
||||||
|
// for days / hours / minutes / seconds. Updates every second.
|
||||||
|
(function () {
|
||||||
|
const els = document.querySelectorAll('.countdown[data-expires]');
|
||||||
|
if (!els.length) return;
|
||||||
|
|
||||||
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const now = new Date();
|
||||||
|
els.forEach((el) => {
|
||||||
|
const target = new Date(el.dataset.expires + 'T23:59:59');
|
||||||
|
let diff = (target.getTime() - now.getTime()) / 1000;
|
||||||
|
const card = el.closest('.alert-card');
|
||||||
|
const negative = diff < 0;
|
||||||
|
diff = Math.abs(diff);
|
||||||
|
|
||||||
|
const days = Math.floor(diff / 86400);
|
||||||
|
const hours = Math.floor((diff % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((diff % 3600) / 60);
|
||||||
|
const seconds = Math.floor(diff % 60);
|
||||||
|
|
||||||
|
const segs = el.querySelectorAll('.countdown-value');
|
||||||
|
if (segs.length === 4) {
|
||||||
|
segs[0].textContent = days;
|
||||||
|
segs[1].textContent = pad(hours);
|
||||||
|
segs[2].textContent = pad(minutes);
|
||||||
|
segs[3].textContent = pad(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove('is-warning', 'is-critical', 'is-expired');
|
||||||
|
if (negative) {
|
||||||
|
card.classList.add('is-expired');
|
||||||
|
} else if (days < 7) {
|
||||||
|
card.classList.add('is-critical');
|
||||||
|
} else if (days < 30) {
|
||||||
|
card.classList.add('is-warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotLabel = el.parentElement && el.parentElement.querySelector('.alert-state-text');
|
||||||
|
if (dotLabel) {
|
||||||
|
if (negative) dotLabel.textContent = 'EXPIRED';
|
||||||
|
else if (days < 7) dotLabel.textContent = 'CRITICAL';
|
||||||
|
else if (days < 30) dotLabel.textContent = 'WARNING';
|
||||||
|
else dotLabel.textContent = 'NOMINAL';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
setInterval(render, 1000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ====== Confirm-on-click for destructive actions ===========================
|
||||||
|
document.querySelectorAll('form[data-confirm]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
if (!window.confirm(form.dataset.confirm)) e.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Account{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// account › security</span>
|
||||||
|
<h1>{{ current_user.username }}</h1>
|
||||||
|
<p class="lead">{{ current_user.email }} · role <span class="role-badge role-{{ current_user.role }}">{{ current_user.role }}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom: 20px;">
|
||||||
|
<header class="panel-header"><h3>Profile avatar</h3></header>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="avatar-row">
|
||||||
|
<div class="avatar-preview">
|
||||||
|
{% if current_user.has_avatar %}
|
||||||
|
<img src="{{ url_for('auth.avatar', user_id=current_user.id) }}?v={{ range(1,100000)|random }}" alt="Current avatar">
|
||||||
|
{% else %}
|
||||||
|
<span class="avatar-initials">{{ current_user.initials }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="avatar-actions">
|
||||||
|
<form method="post" action="{{ url_for('auth.avatar_upload') }}" enctype="multipart/form-data" class="form">
|
||||||
|
{{ avatar_form.csrf_token }}
|
||||||
|
<div>{{ avatar_form.avatar.label }}{{ avatar_form.avatar(accept="image/*") }}</div>
|
||||||
|
<div class="help">PNG, JPG, GIF or WebP. Squared and resized to {{ 256 }}×{{ 256 }} automatically. Max 2 MB.</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
{{ avatar_form.submit(class_="btn btn-primary") }}
|
||||||
|
{% if current_user.has_avatar %}
|
||||||
|
<button type="submit" form="avatar-remove-form" class="btn btn-ghost">Remove avatar</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if current_user.has_avatar %}
|
||||||
|
<form method="post" action="{{ url_for('auth.avatar_remove') }}" id="avatar-remove-form" class="inline-form" data-confirm="Remove your avatar?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header"><h3>Change password</h3></header>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" action="{{ url_for('auth.change_password') }}" class="form" novalidate>
|
||||||
|
{{ password_form.csrf_token }}
|
||||||
|
<div>{{ password_form.current_password.label }}{{ password_form.current_password(autocomplete="current-password") }}</div>
|
||||||
|
<div>{{ password_form.new_password.label }}{{ password_form.new_password(autocomplete="new-password") }}</div>
|
||||||
|
<div>{{ password_form.confirm.label }}{{ password_form.confirm(autocomplete="new-password") }}</div>
|
||||||
|
{{ password_form.submit(class_="btn btn-primary") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header">
|
||||||
|
<h3>Multi-factor authentication</h3>
|
||||||
|
<span class="status-badge {{ 'status-active' if current_user.mfa_enabled else 'status-inactive' }}">
|
||||||
|
<span class="led"></span>{{ 'enabled' if current_user.mfa_enabled else 'disabled' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% if current_user.mfa_enabled %}
|
||||||
|
<p>MFA is currently <strong>active</strong> on your account. You'll be prompted for a 6-digit code on every sign-in.</p>
|
||||||
|
<form method="post" action="{{ url_for('auth.mfa_disable') }}" class="inline-form" data-confirm="Disable MFA? This reduces your account security.">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-danger">Disable MFA</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>MFA is currently <strong>not configured</strong>. Strongly recommended for admin accounts.</p>
|
||||||
|
<a href="{{ url_for('auth.mfa_setup') }}" class="btn btn-primary">Set up MFA now</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<style>@media (max-width: 720px) { div[style*="grid-template-columns: 1fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if alert else 'New' }} alert{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// section 02 › {{ 'edit' if alert else 'new' }}</span>
|
||||||
|
<h1>{{ 'Edit alert' if alert else 'New alert' }}</h1>
|
||||||
|
{% if alert %}<p class="lead">Last updated <span class="mono">{{ alert.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</span> UTC.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('main.alerts_list') }}" class="btn btn-ghost">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="max-width: 720px;">
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div>
|
||||||
|
{{ form.title.label }}{{ form.title(placeholder="e.g. Microsoft 365 Renewal") }}
|
||||||
|
{% for err in form.title.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div>
|
||||||
|
{{ form.category.label }}{{ form.category(placeholder="License, SSL, Domain, Contract …") }}
|
||||||
|
{% for err in form.category.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.expiration_date.label }}{{ form.expiration_date() }}
|
||||||
|
{% for err in form.expiration_date.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.description.label }}{{ form.description(rows=4, placeholder="Optional detail shown on the alert card.") }}
|
||||||
|
{% for err in form.description.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.reminder_days.label }}{{ form.reminder_days() }}
|
||||||
|
<div class="help">Comma-separated days BEFORE expiration when an email reminder fires. Example: <span class="mono">30,14,7,1</span></div>
|
||||||
|
{% for err in form.reminder_days.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="check-row">{{ form.is_active() }} {{ form.is_active.label }}</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
{{ form.submit(class_="btn btn-primary") }}
|
||||||
|
<a href="{{ url_for('main.alerts_list') }}" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Manage Alerts{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// section 02 › add / remove / edit</span>
|
||||||
|
<h1>Manage alerts</h1>
|
||||||
|
<p class="lead">Create, modify, or retire alerts. Only administrators can make changes.</p>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">+ New alert</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Reminders</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in alerts %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ a.title }}</strong>{% if a.description %}<br><span class="dim" style="font-size: .82rem;">{{ a.description[:80] }}{% if a.description|length > 80 %}…{% endif %}</span>{% endif %}</td>
|
||||||
|
<td>{% if a.category %}<span class="alert-cat">{{ a.category }}</span>{% else %}<span class="dim">—</span>{% endif %}</td>
|
||||||
|
<td class="mono">{{ a.expiration_date.isoformat() }}</td>
|
||||||
|
<td class="mono dim">{{ a.reminder_days|join(', ') }}d</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge {{ 'status-active' if a.is_active else 'status-inactive' }}">
|
||||||
|
<span class="led"></span>{{ 'active' if a.is_active else 'inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('main.alert_edit', alert_id=a.id) }}" class="btn btn-ghost btn-sm">Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('main.alert_reset_reminders', alert_id=a.id) }}" class="inline-form" data-confirm="Re-send any pending reminders that were already sent?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Reset reminders</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('main.alert_delete', alert_id=a.id) }}" class="inline-form" data-confirm="Permanently delete '{{ a.title }}'?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="dim">view only</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
<p><strong>No alerts yet.</strong></p>
|
||||||
|
{% if current_user.is_admin %}<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">Create one</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Audit log{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// console › audit log</span>
|
||||||
|
<h1>Audit log</h1>
|
||||||
|
<p class="lead">Sign-ins, configuration changes, and alert mutations. Most-recent first.</p>
|
||||||
|
</div>
|
||||||
|
<div class="log-actions">
|
||||||
|
<a href="{{ url_for('backoffice.audit_log_export') }}" class="btn btn-ghost">⬇ Export CSV</a>
|
||||||
|
<form method="post" action="{{ url_for('backoffice.audit_log_email') }}" class="log-email-form" novalidate>
|
||||||
|
{{ log_email_form.csrf_token }}
|
||||||
|
{{ log_email_form.log_recipient(placeholder="destination@example.com") }}
|
||||||
|
{{ log_email_form.submit(class_="btn btn-primary") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Timestamp (UTC)</th><th>User</th><th>Action</th><th>Details</th><th>IP</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in pagination.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono dim" style="font-size: .82rem; white-space: nowrap;">{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td>{{ e.username or '—' }}</td>
|
||||||
|
<td><span class="mono" style="color: var(--accent);">{{ e.action }}</span></td>
|
||||||
|
<td class="dim">{{ e.details or '' }}</td>
|
||||||
|
<td class="mono dim">{{ e.ip_address or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="dim" style="text-align: center; padding: 32px;">No audit entries yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if pagination.has_prev %}<a href="{{ url_for('backoffice.audit_log', page=pagination.prev_num) }}">‹ prev</a>{% endif %}
|
||||||
|
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||||
|
{% else %}<a href="{{ url_for('backoffice.audit_log', page=p) }}">{{ p }}</a>{% endif %}
|
||||||
|
{% else %}<span>…</span>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}<a href="{{ url_for('backoffice.audit_log', page=pagination.next_num) }}">next ›</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// console › overview</span>
|
||||||
|
<h1>Operations dashboard</h1>
|
||||||
|
<p class="lead">System status as of <span class="mono">{{ today.isoformat() }}</span>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat stat-accent">
|
||||||
|
<div class="stat-label">Active alerts</div>
|
||||||
|
<div class="stat-value">{{ active_alerts }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Users</div>
|
||||||
|
<div class="stat-value">{{ user_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat-warn">
|
||||||
|
<div class="stat-label">Administrators</div>
|
||||||
|
<div class="stat-value">{{ admin_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 1.2fr 1fr; gap: 20px;">
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header">
|
||||||
|
<h3>Expiring soonest</h3>
|
||||||
|
<a href="{{ url_for('backoffice.settings') }}" class="muted mono" style="font-size: .82rem;">settings →</a>
|
||||||
|
</header>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% if expiring_soon %}
|
||||||
|
<table class="table" style="border: none;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Title</th><th>Expires</th><th>Days</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in expiring_soon %}
|
||||||
|
{% set days = (a.expiration_date - today).days %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ a.title }}</strong>{% if a.category %} <span class="alert-cat">{{ a.category }}</span>{% endif %}</td>
|
||||||
|
<td class="mono">{{ a.expiration_date.isoformat() }}</td>
|
||||||
|
<td class="mono" style="color: {% if days < 7 %}var(--danger){% elif days < 30 %}var(--amber){% else %}var(--accent){% endif %};">{{ days }}d</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="dim">No upcoming alerts.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header"><h3>Recent activity</h3>
|
||||||
|
<a href="{{ url_for('backoffice.audit_log') }}" class="muted mono" style="font-size: .82rem;">full log →</a>
|
||||||
|
</header>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% if recent_audit %}
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0; display: grid; gap: 8px;">
|
||||||
|
{% for entry in recent_audit %}
|
||||||
|
<li class="mono" style="font-size: .82rem; display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
||||||
|
<span><span style="color: var(--accent);">{{ entry.action }}</span> {% if entry.username %}<span class="dim">· {{ entry.username }}</span>{% endif %}{% if entry.details %}<br><span class="dim">{{ entry.details }}</span>{% endif %}</span>
|
||||||
|
<span class="dim" style="white-space: nowrap;">{{ entry.timestamp.strftime('%m-%d %H:%M') }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="dim">No activity yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1.2fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Mail configuration{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// console › mail server</span>
|
||||||
|
<h1>SMTP configuration</h1>
|
||||||
|
<p class="lead">External mail server used to dispatch alert reminders. Edits take effect immediately.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px;">
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header"><h3>Server settings</h3></header>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" action="{{ url_for('backoffice.mail_config') }}" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div style="grid-column: span 2;">
|
||||||
|
{{ form.smtp_host.label }}{{ form.smtp_host(placeholder="smtp.example.com") }}
|
||||||
|
{% for err in form.smtp_host.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.smtp_port.label }}{{ form.smtp_port() }}
|
||||||
|
{% for err in form.smtp_port.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.smtp_encryption.label }}{{ form.smtp_encryption() }}
|
||||||
|
<div class="help">587 → STARTTLS · 465 → SSL/TLS · 25 → none</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.smtp_username.label }}{{ form.smtp_username(autocomplete="off") }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.smtp_password.label }}{{ form.smtp_password(autocomplete="new-password") }}
|
||||||
|
<div class="help">Leave blank to keep the current password.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.smtp_from_address.label }}{{ form.smtp_from_address() }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.smtp_from_name.label }}{{ form.smtp_from_name() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h4 style="margin: 4px 0 2px; font-family: var(--font-mono); color: var(--text-secondary);">Sent-folder copy (IMAP)</h4>
|
||||||
|
<p class="help" style="margin-top: 0;">When enabled, a copy of every message the platform sends is saved to the mailbox's Sent folder over IMAP, so it appears in your normal mail client.</p>
|
||||||
|
<div class="check-row" style="margin-bottom: 12px;">{{ form.imap_enabled() }} {{ form.imap_enabled.label }}</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div style="grid-column: span 2;">
|
||||||
|
{{ form.imap_host.label }}{{ form.imap_host(placeholder="imap.purelymail.com") }}
|
||||||
|
{% for err in form.imap_host.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.imap_port.label }}{{ form.imap_port(placeholder="993") }}
|
||||||
|
{% for err in form.imap_port.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.imap_sent_folder.label }}{{ form.imap_sent_folder(placeholder="Sent") }}
|
||||||
|
<div class="help">Usually <code>Sent</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.imap_username.label }}{{ form.imap_username(autocomplete="off") }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.imap_password.label }}{{ form.imap_password(autocomplete="new-password") }}
|
||||||
|
<div class="help">Blank keeps current; if never set, the SMTP password is used.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form.submit(class_="btn btn-primary") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header"><h3>Send test email</h3></header>
|
||||||
|
<div class="panel-body">
|
||||||
|
<p class="muted" style="font-size: .9rem;">Confirms credentials, port, and encryption are correct.</p>
|
||||||
|
<form method="post" action="{{ url_for('backoffice.mail_test') }}" class="form" novalidate>
|
||||||
|
{{ test_form.csrf_token }}
|
||||||
|
<div>{{ test_form.test_recipient.label }}{{ test_form.test_recipient(placeholder="you@example.com") }}</div>
|
||||||
|
{{ test_form.submit(class_="btn btn-ghost btn-block") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1.4fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Mail log{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// console › mail log</span>
|
||||||
|
<h1>Mail log</h1>
|
||||||
|
<p class="lead">Every message the platform has dispatched, whether a copy reached the Sent folder, and its delivery result. Most-recent first.</p>
|
||||||
|
</div>
|
||||||
|
<div class="log-actions">
|
||||||
|
<a href="{{ url_for('backoffice.mail_log_export') }}" class="btn btn-ghost">⬇ Export CSV</a>
|
||||||
|
<form method="post" action="{{ url_for('backoffice.mail_log_email') }}" class="log-email-form" novalidate>
|
||||||
|
{{ log_email_form.csrf_token }}
|
||||||
|
{{ log_email_form.log_recipient(placeholder="destination@example.com") }}
|
||||||
|
{{ log_email_form.submit(class_="btn btn-primary") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp (UTC)</th><th>Kind</th><th>Recipients</th><th>Subject</th>
|
||||||
|
<th>Status</th><th>Sent copy</th><th>By</th><th>Info</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in pagination.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono dim" style="font-size: .82rem; white-space: nowrap;">{{ m.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td><span class="mono" style="color: var(--info);">{{ m.kind }}</span></td>
|
||||||
|
<td class="dim" style="font-size: .85rem;">{{ m.recipients or '—' }}</td>
|
||||||
|
<td>{{ m.subject or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if m.status == 'sent' %}
|
||||||
|
<span class="status-badge status-active"><span class="led"></span>sent</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge" style="color: var(--danger);"><span class="led" style="background: var(--danger);"></span>failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if m.sent_copy == 'saved' %}
|
||||||
|
<span class="mono" style="color: var(--accent);">saved</span>
|
||||||
|
{% elif m.sent_copy == 'failed' %}
|
||||||
|
<span class="mono" style="color: var(--danger);">failed</span>
|
||||||
|
{% elif m.sent_copy == 'disabled' %}
|
||||||
|
<span class="mono dim">disabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="mono dim">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono dim" style="font-size: .82rem;">{{ m.triggered_by or 'system' }}</td>
|
||||||
|
<td class="dim" style="font-size: .8rem;">{{ m.info or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="dim" style="text-align: center; padding: 32px;">No mail has been sent yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if pagination.has_prev %}<a href="{{ url_for('backoffice.mail_log', page=pagination.prev_num) }}">‹ prev</a>{% endif %}
|
||||||
|
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
{% if p == pagination.page %}<span class="current">{{ p }}</span>
|
||||||
|
{% else %}<a href="{{ url_for('backoffice.mail_log', page=p) }}">{{ p }}</a>{% endif %}
|
||||||
|
{% else %}<span>…</span>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}<a href="{{ url_for('backoffice.mail_log', page=pagination.next_num) }}">next ›</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// console › settings</span>
|
||||||
|
<h1>Global settings</h1>
|
||||||
|
<p class="lead">Default reminder cadence and scheduler controls.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header"><h3>Default reminder schedule</h3></header>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div>
|
||||||
|
{{ form.default_reminder_days.label }}{{ form.default_reminder_days() }}
|
||||||
|
<div class="help">When creating a new alert, this is the default. Existing alerts are unaffected.</div>
|
||||||
|
{% for err in form.default_reminder_days.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ form.submit(class_="btn btn-primary") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-header"><h3>Scheduler</h3></header>
|
||||||
|
<div class="panel-body">
|
||||||
|
<p>Reminder dispatch runs every <strong class="mono" style="color: var(--accent);">{{ scheduler_interval }}</strong> minute(s).</p>
|
||||||
|
<p class="muted" style="font-size: .9rem;">Configured via the <code>SCHEDULER_INTERVAL_MINUTES</code> environment variable. Restart the container to change it.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<p>You can trigger a check immediately to verify reminders dispatch correctly.</p>
|
||||||
|
<form method="post" action="{{ url_for('backoffice.run_check_now') }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-ghost" type="submit">▶ Run reminder check now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if user else 'New' }} user{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// section 03 › {{ 'edit' if user else 'new' }} user</span>
|
||||||
|
<h1>{{ 'Edit user' if user else 'New user' }}</h1>
|
||||||
|
{% if user %}<p class="lead">Created <span class="mono">{{ user.created_at.strftime('%Y-%m-%d') }}</span></p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('backoffice.users_list') }}" class="btn btn-ghost">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="max-width: 640px;">
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div>{{ form.username.label }}{{ form.username() }}
|
||||||
|
{% for err in form.username.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>{{ form.email.label }}{{ form.email() }}
|
||||||
|
{% for err in form.email.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div>{{ form.role.label }}{{ form.role() }}</div>
|
||||||
|
<div class="check-row" style="align-self: end;">{{ form.is_active() }} {{ form.is_active.label }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.password.label }}{{ form.password(autocomplete="new-password") }}
|
||||||
|
<div class="help">{% if user %}Leave blank to keep the existing password.{% else %}Min {{ config.PASSWORD_MIN_LENGTH }} characters.{% endif %}</div>
|
||||||
|
{% for err in form.password.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
{{ form.submit(class_="btn btn-primary") }}
|
||||||
|
<a href="{{ url_for('backoffice.users_list') }}" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Users{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// section 03 › users & permissions</span>
|
||||||
|
<h1>Users</h1>
|
||||||
|
<p class="lead">Manage accounts, roles, MFA, and lockouts.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('backoffice.user_new') }}" class="btn btn-primary">+ New user</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th><th>Email</th><th>Role</th><th>MFA</th><th>Status</th><th>Last login</th><th class="actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ u.username }}</strong>{% if u.id == current_user.id %} <span class="dim mono" style="font-size: .72rem;">(you)</span>{% endif %}</td>
|
||||||
|
<td class="mono dim" style="font-size: .85rem;">{{ u.email }}</td>
|
||||||
|
<td><span class="role-badge role-{{ u.role }}">{{ u.role }}</span></td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge {{ 'status-active' if u.mfa_enabled else 'status-inactive' }}">
|
||||||
|
<span class="led"></span>{{ 'on' if u.mfa_enabled else 'off' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.is_locked %}
|
||||||
|
<span class="status-badge" style="color: var(--danger);"><span class="led" style="background: var(--danger);"></span>locked</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge {{ 'status-active' if u.is_active else 'status-inactive' }}"><span class="led"></span>{{ 'active' if u.is_active else 'disabled' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono dim" style="font-size: .82rem;">
|
||||||
|
{% if u.last_login_at %}{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') }}{% else %}never{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="{{ url_for('backoffice.user_edit', user_id=u.id) }}" class="btn btn-ghost btn-sm">Edit</a>
|
||||||
|
{% if u.mfa_enabled %}
|
||||||
|
<form method="post" action="{{ url_for('backoffice.user_reset_mfa', user_id=u.id) }}" class="inline-form" data-confirm="Reset MFA for {{ u.username }}?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Reset MFA</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.is_locked %}
|
||||||
|
<form method="post" action="{{ url_for('backoffice.user_unlock', user_id=u.id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Unlock</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form method="post" action="{{ url_for('backoffice.user_delete', user_id=u.id) }}" class="inline-form" data-confirm="Permanently delete user '{{ u.username }}'?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>{% block title %}{{ brand_name }}{% endblock %} — {{ brand_name }}{% if is_backoffice %} · backoffice{% endif %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
<script>
|
||||||
|
// Theme bootstrap — set BEFORE first paint to avoid flash
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('alerthub-theme');
|
||||||
|
var theme = saved || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="{{ 'is-backoffice' if is_backoffice else 'is-frontoffice' }}">
|
||||||
|
|
||||||
|
<div class="grid-bg" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-inner">
|
||||||
|
<a class="brand" href="{{ url_for('main.index') if not is_backoffice else url_for('backoffice.dashboard') }}">
|
||||||
|
<span class="brand-mark">▮▮</span>
|
||||||
|
<span class="brand-name">{{ brand_name }}</span>
|
||||||
|
<span class="brand-tag">{{ 'BACKOFFICE' if is_backoffice else 'FRONTOFFICE' }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated and not session.get('mfa_pending') %}
|
||||||
|
<nav class="topbar-nav">
|
||||||
|
{% if not is_backoffice %}
|
||||||
|
<a href="{{ url_for('main.index') }}" class="navlink {% if request.endpoint == 'main.index' %}active{% endif %}">
|
||||||
|
<span class="dot"></span>View
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.alerts_list') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('main.alert') %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Manage Alerts
|
||||||
|
</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('backoffice.dashboard') }}" class="navlink navlink-cross {% if is_backoffice %}active{% endif %}" title="Open the admin backoffice">
|
||||||
|
<span class="dot dot-admin"></span>Backoffice
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('main.index') }}" class="navlink navlink-cross" title="Back to the alerts frontoffice">
|
||||||
|
<span class="dot"></span>Frontoffice
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backoffice.dashboard') }}" class="navlink {% if request.endpoint == 'backoffice.dashboard' %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backoffice.users_list') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('backoffice.user') %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Users
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backoffice.mail_config') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('backoffice.mail') %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Mail
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backoffice.settings') }}" class="navlink {% if request.endpoint == 'backoffice.settings' %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Settings
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backoffice.audit_log') }}" class="navlink {% if request.endpoint == 'backoffice.audit_log' %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Audit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backoffice.mail_log') }}" class="navlink {% if request.endpoint == 'backoffice.mail_log' %}active{% endif %}">
|
||||||
|
<span class="dot"></span>Mail Log
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="{{ url_for('auth.account') }}" class="user-chip" title="Account">
|
||||||
|
{% if current_user.has_avatar %}
|
||||||
|
<img class="user-avatar user-avatar-img" src="{{ url_for('auth.avatar', user_id=current_user.id) }}" alt="{{ current_user.username }}">
|
||||||
|
{% else %}
|
||||||
|
<span class="user-avatar">{{ current_user.initials }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="user-meta">
|
||||||
|
<span class="user-name">{{ current_user.username }}</span>
|
||||||
|
<span class="user-role role-{{ current_user.role }}">{{ current_user.role }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-ghost" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-stack">
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="flash flash-{{ cat }}">
|
||||||
|
<span class="flash-icon"></span>
|
||||||
|
<span class="flash-text">{{ msg }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% if request.endpoint != 'auth.login' %}
|
||||||
|
<footer class="footer">
|
||||||
|
<span class="kbd">{{ brand_name }}</span>
|
||||||
|
<span class="footer-sep">·</span>
|
||||||
|
<span>{{ 'admin console' if is_backoffice else 'alert console' }}</span>
|
||||||
|
<span class="footer-copy">{{ footer_text }}</span>
|
||||||
|
<span class="status-pill"><span class="led"></span>operational</span>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ code }} · Error{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-shell">
|
||||||
|
<div class="auth-card" style="text-align: center;">
|
||||||
|
<div class="auth-prompt">error</div>
|
||||||
|
<h1 style="font-family: var(--font-mono); font-size: 3rem; color: var(--magenta);">{{ code }}</h1>
|
||||||
|
<p class="auth-sub">{{ message }}</p>
|
||||||
|
<a href="{{ url_for('main.index') if not is_backoffice else url_for('backoffice.dashboard') }}" class="btn btn-primary">← Return home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}View · Alerts{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// section 01 › view</span>
|
||||||
|
<h1>Active alerts</h1>
|
||||||
|
<p class="lead">Live countdown to expiration. Reminders are dispatched automatically by email per the configured schedule.</p>
|
||||||
|
</div>
|
||||||
|
<div class="row-gap">
|
||||||
|
<span class="kbd">today: <strong style="color: var(--accent);">{{ today_iso }}</strong></span>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">+ New alert</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<div class="alerts-grid">
|
||||||
|
{% for a in alerts %}
|
||||||
|
{% set state = 'is-expired' if a.days_left < 0 else ('is-critical' if a.days_left < 7 else ('is-warning' if a.days_left < 30 else '')) %}
|
||||||
|
{% set state_text = 'EXPIRED' if a.days_left < 0 else ('CRITICAL' if a.days_left < 7 else ('WARNING' if a.days_left < 30 else 'NOMINAL')) %}
|
||||||
|
<article class="alert-card {{ state }}">
|
||||||
|
<header class="alert-head">
|
||||||
|
<h3 class="alert-title">{{ a.title }}</h3>
|
||||||
|
{% if a.category %}<span class="alert-cat">{{ a.category }}</span>{% endif %}
|
||||||
|
</header>
|
||||||
|
{% if a.description %}<p class="alert-desc">{{ a.description }}</p>{% endif %}
|
||||||
|
|
||||||
|
<div class="alert-message">
|
||||||
|
<strong>{{ a.title }}</strong> · expires {{ a.expiration_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="countdown" data-expires="{{ a.expiration_date }}">
|
||||||
|
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">days</span></div>
|
||||||
|
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">hours</span></div>
|
||||||
|
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">min</span></div>
|
||||||
|
<div class="countdown-segment"><span class="countdown-value">—</span><span class="countdown-label">sec</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert-meta">
|
||||||
|
<span><span class="alert-status-dot"></span><span class="alert-state-text">{{ state_text }}</span></span>
|
||||||
|
<span>reminders: {{ a.reminder_days|join(', ') }}d</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-icon">▮▮</div>
|
||||||
|
<p><strong>No active alerts.</strong></p>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<p class="dim">Add your first one — license renewals, certificates, contracts, …</p>
|
||||||
|
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">Create the first alert</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="dim">Ask an administrator to create alerts for your team.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sign in{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-prompt">authenticate</div>
|
||||||
|
<h1>Sign in to {{ brand_name }}</h1>
|
||||||
|
<p class="auth-sub">{% if is_backoffice %}Administrator console.{% else %}Alert operations console.{% endif %}</p>
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div>
|
||||||
|
{{ form.username.label }}
|
||||||
|
{{ form.username(autocomplete="username", autofocus=True) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(autocomplete="current-password") }}
|
||||||
|
</div>
|
||||||
|
{{ form.submit(class_="btn btn-primary btn-block") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Verify identity{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-prompt">verify</div>
|
||||||
|
<h1>Two-factor authentication</h1>
|
||||||
|
<p class="auth-sub">Enter the 6-digit code from your authenticator app.</p>
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div>
|
||||||
|
{{ form.code.label }}
|
||||||
|
{{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", autofocus=True, maxlength=6) }}
|
||||||
|
</div>
|
||||||
|
{{ form.submit(class_="btn btn-primary btn-block") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Set up MFA{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-tag">// account › mfa</span>
|
||||||
|
<h1>Enable multi-factor authentication</h1>
|
||||||
|
<p class="lead">Scan the QR code with your authenticator (Google Authenticator, Authy, 1Password, …) and enter the 6-digit code to confirm.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="max-width: 540px; margin: 0 auto;">
|
||||||
|
<div class="panel-body">
|
||||||
|
<img src="data:image/png;base64,{{ qr_b64 }}" alt="MFA QR code" class="mfa-qr">
|
||||||
|
<p class="dim" style="text-align:center; font-size: .82rem; margin: 16px 0 4px;">Manual key (if you can't scan):</p>
|
||||||
|
<div class="mfa-secret">{{ secret }}</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div>
|
||||||
|
{{ form.code.label }}
|
||||||
|
{{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", maxlength=6) }}
|
||||||
|
<div class="help">Enter the current 6-digit code shown in your authenticator.</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
{{ form.submit(class_="btn btn-primary") }}
|
||||||
|
<a href="{{ url_for('auth.account') }}" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Initial setup{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-prompt">first-run setup</div>
|
||||||
|
<h1>Create administrator</h1>
|
||||||
|
<p class="auth-sub">No accounts exist yet. Create the first administrator to secure {{ brand_name }}.</p>
|
||||||
|
<form method="post" class="form" novalidate>
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div>
|
||||||
|
{{ form.username.label }}
|
||||||
|
{{ form.username(autocomplete="username", autofocus=True) }}
|
||||||
|
{% for err in form.username.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.email.label }}
|
||||||
|
{{ form.email(autocomplete="email") }}
|
||||||
|
{% for err in form.email.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(autocomplete="new-password") }}
|
||||||
|
{% for err in form.password.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form.confirm.label }}
|
||||||
|
{{ form.confirm(autocomplete="new-password") }}
|
||||||
|
{% for err in form.confirm.errors %}<div class="error">{{ err }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ form.submit(class_="btn btn-primary btn-block") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# docker-compose.yml
|
||||||
|
# All runtime configuration is read from the .env file in the same folder.
|
||||||
|
# This file intentionally contains NO secrets and NO environment values.
|
||||||
|
|
||||||
|
services:
|
||||||
|
alerthub:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: alerthub:latest
|
||||||
|
container_name: alerthub
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${FRONTOFFICE_PORT}:${FRONTOFFICE_PORT}"
|
||||||
|
volumes:
|
||||||
|
- alerthub_data:/data
|
||||||
|
# Hardening
|
||||||
|
read_only: false # SQLite needs to write to /data; the rest of FS is owned by non-root user
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${FRONTOFFICE_PORT}/login"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 15s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
alerthub_data:
|
||||||
|
name: alerthub_data
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Starts the unified AlertHub app (frontoffice + /backoffice) on a single port.
|
||||||
|
# POSIX /bin/sh (dash) compatible. Uses exec so Gunicorn becomes the container's
|
||||||
|
# main process and receives signals directly for clean, graceful shutdown.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PORT="${APP_PORT:-${FRONTOFFICE_PORT:-8080}}"
|
||||||
|
# A single worker guarantees exactly one reminder scheduler. Concurrency is
|
||||||
|
# handled with threads, which is plenty for this internal tool. You can raise
|
||||||
|
# GUNICORN_THREADS if needed; leave GUNICORN_WORKERS at 1 to avoid running the
|
||||||
|
# scheduler more than once.
|
||||||
|
WORKERS="${GUNICORN_WORKERS:-1}"
|
||||||
|
THREADS="${GUNICORN_THREADS:-4}"
|
||||||
|
|
||||||
|
echo ">> AlertHub starting (frontoffice + /backoffice on one port)"
|
||||||
|
echo " listening -> 0.0.0.0:${PORT}"
|
||||||
|
|
||||||
|
exec gunicorn --bind "0.0.0.0:${PORT}" \
|
||||||
|
--workers "${WORKERS}" \
|
||||||
|
--threads "${THREADS}" \
|
||||||
|
--timeout 60 \
|
||||||
|
--graceful-timeout 30 \
|
||||||
|
--access-logfile - \
|
||||||
|
--error-logfile - \
|
||||||
|
"wsgi:application"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Flask==3.1.3
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-WTF==1.2.2
|
||||||
|
WTForms==3.2.1
|
||||||
|
SQLAlchemy==2.0.36
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
bcrypt==4.2.1
|
||||||
|
pyotp==2.9.0
|
||||||
|
qrcode[pil]==8.0
|
||||||
|
Pillow==11.1.0
|
||||||
|
APScheduler==3.11.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
email-validator==2.2.0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""WSGI entry point for AlertHub.
|
||||||
|
|
||||||
|
Serves the whole application — the frontoffice at the root and the backoffice
|
||||||
|
under /backoffice — on a single port.
|
||||||
|
"""
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
application = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
port = int(os.environ.get("APP_PORT", os.environ.get("FRONTOFFICE_PORT", "8080")))
|
||||||
|
application.run(host="0.0.0.0", port=port, debug=False)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""WSGI entry point for the backoffice (port 9001)."""
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
application = create_app("backoffice")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
port = int(os.environ.get("BACKOFFICE_PORT", "9001"))
|
||||||
|
application.run(host="0.0.0.0", port=port, debug=False)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""WSGI entry point for the frontoffice (port 8080)."""
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
application = create_app("frontoffice")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
port = int(os.environ.get("FRONTOFFICE_PORT", "8080"))
|
||||||
|
application.run(host="0.0.0.0", port=port, debug=False)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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.
@@ -0,0 +1,21 @@
|
|||||||
|
# --- 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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
approvalflow:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""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}"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
werkzeug>=3.0
|
||||||
|
gunicorn>=21.0
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* 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; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Workflows & requests — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Workflows & 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 %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!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 & 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>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% 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 %}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="{{ url_for('admin') }}">← 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 %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% 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 %}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="{{ url_for('admin') }}">← 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.
@@ -0,0 +1,21 @@
|
|||||||
|
# --- 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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
approvalflow:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""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}"
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
werkzeug>=3.0
|
||||||
|
gunicorn>=21.0
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* 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; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Workflows & requests — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Workflows & 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 %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!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 & 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>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% 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 %}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="{{ url_for('admin') }}">← 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 & 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 %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% 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 %}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="{{ url_for('admin') }}">← 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 manager@example.com 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.
@@ -0,0 +1,13 @@
|
|||||||
|
# --- 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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
approvalflow:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""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}"
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
werkzeug>=3.0
|
||||||
|
gunicorn>=21.0
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* 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; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Workflows & requests — ApprovalFlow{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Workflows & 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>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">{% 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="4" 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>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="stamp {{ r.status }}">{{ r.status }}</span></td>
|
||||||
|
<td class="mono muted">{{ r.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="muted">No requests yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user