This commit is contained in:
jpmvaz
2026-09-13 20:03:24 +01:00
commit 0ebb8e7f4b
270 changed files with 23789 additions and 0 deletions
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,94 @@
# 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.
- **Attachments.** A request can include one uploaded file (up to 15 MB). It is attached to **every approval mail** sent along the chain, and admins can download it from the request page. Files are stored next to the database in `data/attachments/`.
- **MFA.** Any user can enable two-factor authentication from their Account page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device.
- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference.
- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback).
- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance.
- **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 |
| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS |
Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out.
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}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{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/mail-log helpers
pipeline.py approval chain + decision/notification logic
mailer.py Purelymail SMTP sending, Sent-folder copy, mail log
templates/ pages static/style.css styling
```
@@ -0,0 +1,539 @@
"""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, send_file, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
import db
import pipeline
import totp
from mailer import send_mail
from db import DEFAULT_TEMPLATES
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# 15 MB attachment limit (+ small allowance for the rest of the form)
app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024
@app.errorhandler(413)
def too_large(e):
flash("The uploaded file is too large — the limit is 15 MB.", "error")
return redirect(url_for("index"))
# ---------------------------------------------------------------- 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
def _start_session(user, next_url=None):
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
return redirect(next_url or url_for("index"))
@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", "")):
if user["mfa_secret"]:
session.clear()
session["mfa_pending"] = user["id"]
session["mfa_next"] = request.args.get("next") or ""
return redirect(url_for("login_mfa"))
return _start_session(user, request.args.get("next"))
flash("Wrong username or password.", "error")
return render_template("login.html")
@app.route("/login/mfa", methods=["GET", "POST"])
def login_mfa():
uid = session.get("mfa_pending")
if not uid:
return redirect(url_for("login"))
user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
if user is None or not user["mfa_secret"]:
session.clear()
return redirect(url_for("login"))
if request.method == "POST":
if totp.verify(user["mfa_secret"], request.form.get("code", "")):
return _start_session(user, session.get("mfa_next") or None)
flash("Wrong verification code — try again.", "error")
return render_template("login.html", mfa_stage=True, username=user["username"])
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- account (MFA)
@app.route("/account", methods=["GET", "POST"])
@login_required
def account():
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
setup_secret = session.get("mfa_setup")
if request.method == "POST":
act = request.form.get("action")
if act == "begin":
session["mfa_setup"] = totp.new_secret()
return redirect(url_for("account"))
if act == "cancel":
session.pop("mfa_setup", None)
return redirect(url_for("account"))
if act == "confirm" and setup_secret:
if totp.verify(setup_secret, request.form.get("code", "")):
g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?",
(setup_secret, user["id"]))
g.db.commit()
session.pop("mfa_setup", None)
flash("Two-factor authentication is now enabled on your account.", "ok")
else:
flash("That code didn't match — scan the QR code and try again.", "error")
return redirect(url_for("account"))
if act == "disable" and user["mfa_secret"]:
if totp.verify(user["mfa_secret"], request.form.get("code", "")):
g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],))
g.db.commit()
flash("Two-factor authentication disabled.", "ok")
else:
flash("Wrong code — MFA was not disabled.", "error")
return redirect(url_for("account"))
otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else ""
return render_template("account.html", user=user,
setup_secret=setup_secret, otpauth=otpauth)
# ---------------------------------------------------------------- main page
@app.route("/", methods=["GET", "POST"])
@login_required
def index():
workflows = user_workflows(session["user_id"])
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
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
requester = request.form.get("request_email", "").strip().lower()
subject = request.form.get("subject", "").strip()
body = request.form.get("body", "").strip()
upload = request.files.get("attachment")
file_data = upload.read() if upload and upload.filename else b""
if not requester or "@" not in requester:
flash("Request's e-mail is required and must be a valid address.", "error")
elif not subject or not body:
flash("Subject and message are both required.", "error")
elif len(file_data) > db.MAX_ATTACHMENT_BYTES:
flash("The uploaded file is too large — the limit is 15 MB.", "error")
else:
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']}")
if file_data:
stored = f"{rid}_{secure_filename(upload.filename) or 'attachment'}"
os.makedirs(db.ATTACH_DIR, exist_ok=True)
with open(db.attachment_path(stored), "wb") as fh:
fh.write(file_data)
g.db.execute("UPDATE requests SET attachment=? WHERE id=?", (stored, rid))
db.log(g.db, rid, "attachment saved",
f"'{upload.filename}' ({len(file_data)} bytes) — included in every approval mail")
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.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15",
(session["username"], (user["email"] or "-").lower())).fetchall()
return render_template("index.html", workflows=workflows, my_requests=my_requests,
user_email=user["email"] or "")
# ---------------------------------------------------------------- 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
reason = request.form.get("reason", "").strip()
if action == "deny" and not reason:
return render_template("decision.html", stage="confirm", req=req, action=action,
approver=approver, chain=chain,
error="A reason for denial is required."), 400
status, req2, message = pipeline.decide(token, action, decided_by, reason=reason)
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")
reason = request.form.get("reason", "").strip()
if action == "deny" and not reason:
flash("A reason for denial is required.", "error")
return redirect(url_for("admin_request", rid=rid))
if action in ("approve", "deny"):
pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason)
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)
@app.route("/admin/request/<int:rid>/attachment")
@admin_required
def request_attachment(rid):
req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone()
if req is None or not req["attachment"]:
abort(404)
path = db.attachment_path(req["attachment"])
if not os.path.exists(path):
abort(404)
return send_file(path, as_attachment=True,
download_name=req["attachment"].split("_", 1)[-1])
@app.route("/admin/request/<int:rid>/send-log", methods=["POST"])
@admin_required
def send_request_log(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)
to = request.form.get("email", "").strip()
if not to or "@" not in to:
flash("A valid e-mail address is required to send the log.", "error")
return redirect(url_for("admin_request", rid=rid))
logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall()
chain = pipeline.approver_chain(req)
participants = json.loads(req["participants"])
lines = [
f"Full log of request #{req['id']}{req['subject']}",
"=" * 60,
f"Workflow: {req['wf_name']}",
f"Requester: {req['requester']}",
f"Created: {req['created_at']}",
f"Status: {req['status'].upper()}",
]
if req["decided_by"]:
lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}")
if req["deny_reason"]:
lines.append(f"Denial reason: {req['deny_reason']}")
lines += [
f"Approval chain: {' -> '.join(chain)}",
f"People involved: {', '.join(participants)}",
"",
"Message:",
req["body"],
"",
"Event log:",
"-" * 60,
]
for l in logs:
lines.append(f"{l['at']} | {l['event']}")
if l["detail"]:
lines.append(f"{'':25} {l['detail']}")
body = "\n".join(lines)
ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}",
body, request_id=rid)
db.log(g.db, rid, "log sent" if ok else "mail error",
f"Full log emailed to {to} by {session['username']}{detail}")
g.db.commit()
flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}",
"ok" if ok else "error")
return redirect(url_for("admin_request", rid=rid))
# ---------------------------------------------------------------- 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,
images=_email_images())
# ---------------------------------------------------------------- admin: mail status
@app.route("/admin/mail")
@admin_required
def mail_status():
rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall()
stats = {s["status"]: s["c"] for s in
g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")}
copy_fails = g.db.execute(
"SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"]
configured = bool(os.environ.get("MAIL_USER"))
return render_template("mail_status.html", rows=rows, stats=stats,
copy_fails=copy_fails, configured=configured)
# ---------------------------------------------------------------- admin: email images
def _email_images():
os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True)
return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR)
if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES)
@app.route("/admin/email-images/upload", methods=["POST"])
@admin_required
def email_image_upload():
nxt = request.form.get("next") or url_for("admin")
upload = request.files.get("image")
if not upload or not upload.filename:
flash("Choose an image file to upload.", "error")
return redirect(nxt)
name = secure_filename(upload.filename)
ext = os.path.splitext(name)[1].lower()
if ext not in db.EMAIL_IMAGE_TYPES:
flash("Only PNG, JPG and GIF images can be used in emails.", "error")
return redirect(nxt)
data = upload.read()
if len(data) > db.MAX_EMAIL_IMAGE_BYTES:
flash("Email images are limited to 2 MB each.", "error")
return redirect(nxt)
os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True)
if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)):
flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error")
return redirect(nxt)
with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh:
fh.write(data)
flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok")
return redirect(nxt)
@app.route("/admin/email-images/delete", methods=["POST"])
@admin_required
def email_image_delete():
nxt = request.form.get("next") or url_for("admin")
name = secure_filename(request.form.get("name", ""))
path = os.path.join(db.EMAIL_IMG_DIR, name)
if name and os.path.isfile(path):
os.remove(path)
flash(f"Image “{name}” deleted.", "ok")
return redirect(nxt)
@app.route("/admin/email-images/<name>")
@admin_required
def email_image(name):
name = secure_filename(name)
path = os.path.join(db.EMAIL_IMG_DIR, name)
if not (name and os.path.isfile(path)):
abort(404)
return send_file(path)
# ---------------------------------------------------------------- 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 == "mfa_off":
g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?",
(f.get("user_id", type=int),))
flash("MFA disabled for that user — they can re-enable it from their Account page.", "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,227 @@
"""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"))
ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments")
EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images")
MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image
EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"}
MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB
def attachment_path(stored_name):
return os.path.join(ATTACH_DIR, stored_name)
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 '',
mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled
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 '',
deny_reason TEXT NOT NULL DEFAULT '',
attachment TEXT NOT NULL DEFAULT '', -- stored filename of the uploaded file, if any
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
step INTEGER NOT NULL, -- 1-based position in the chain
decided_by TEXT NOT NULL,
decision TEXT NOT NULL, -- approved / denied
at TEXT NOT NULL
);
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 ''
);
CREATE TABLE IF NOT EXISTS mail_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
request_id INTEGER, -- related request, if any
recipients TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL, -- sent / error / dev
detail TEXT DEFAULT '', -- SMTP error message, if any
sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder
);
"""
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"
"Created: {created_at}\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.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Created: {created_at}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Summary:\n{summary}\n"
),
"denied_subject": "[{workflow}] Denied: {subject}",
"denied_body": (
"The following request was DENIED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Created: {created_at}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Reason for denial:\n{deny_reason}\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, timeout=15)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA busy_timeout = 15000")
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.")
if rcols and "deny_reason" not in rcols:
con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''")
print("[migrate] Added deny_reason to requests.")
if rcols and "attachment" not in rcols:
con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''")
print("[migrate] Added attachment to requests.")
ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")}
if ucols and "mfa_secret" not in ucols:
con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''")
print("[migrate] Added mfa_secret to users.")
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,145 @@
"""Outbound email via Purelymail, and template rendering.
Preconfigured for Purelymail — only the account's login and password are needed:
MAIL_USER=you@yourdomain.com (or you@purelymail.com)
MAIL_PASSWORD=... (use an App Password if 2FA is enabled)
Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a
copy is saved to the account's Sent folder over IMAP
(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is
recorded in the mail_log table shown on the admin "Mail status" page.
"""
import html as html_mod
import imaplib
import os
import smtplib
import time
from email.message import EmailMessage
from email.utils import make_msgid
import db
SMTP_HOST = "smtp.purelymail.com"
SMTP_PORT = 465 # SSL/TLS
IMAP_HOST = "imap.purelymail.com"
IMAP_PORT = 993 # SSL/TLS
SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items")
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 _save_to_sent(user, password, msg) -> str:
"""Append a copy of the message to the account's Sent folder. Returns a status string."""
try:
with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap:
imap.login(user, password)
raw = msg.as_bytes()
stamp = imaplib.Time2Internaldate(time.time())
last_err = ""
for folder in SENT_FOLDER_CANDIDATES:
status, resp = imap.append(folder, r"(\Seen)", stamp, raw)
if status == "OK":
return f"saved to '{folder}'"
last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else ""
# No known folder worked — create "Sent" and try once more.
imap.create("Sent")
status, resp = imap.append("Sent", r"(\Seen)", stamp, raw)
if status == "OK":
return "saved to 'Sent' (folder created)"
return f"failed: {last_err or 'could not append to any Sent folder'}"
except Exception as e:
return f"failed: {e}"
def _record(request_id, recipients, subject, status, detail, sent_copy):
try:
con = db.get_db()
con.execute(
"INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)"
" VALUES (?,?,?,?,?,?,?)",
(db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy))
con.commit()
con.close()
except Exception as e:
print(f"[mail] Could not record mail log entry: {e}")
def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None):
"""Send an email through Purelymail, keep a copy in the Sent folder, and
record the attempt in the mail log. Returns (ok, detail).
attachments: optional list of (filename, bytes, maintype, subtype).
inline_images: optional dict {marker_name: (bytes, subtype)}. When given,
an HTML alternative is built where each "{image:marker_name}"
in the body becomes an embedded <img>; the plain-text part
shows "[image: marker_name]" instead.
"""
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
if inline_images:
text = body
for name in inline_images:
text = text.replace("{image:" + name + "}", f"[image: {name}]")
msg.set_content(text)
html = html_mod.escape(body).replace("\n", "<br>\n")
cids = {}
for name in inline_images:
cid = make_msgid(domain="martinhal.approvalflow")
cids[name] = cid
html = html.replace("{image:" + name + "}",
f'<img src="cid:{cid[1:-1]}" alt="{html_mod.escape(name)}" '
'style="max-width:100%">')
msg.add_alternative(
f'<div style="font-family:sans-serif;font-size:15px;line-height:1.5">{html}</div>',
subtype="html")
html_part = msg.get_payload()[-1]
for name, (data, subtype) in inline_images.items():
html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name])
else:
msg.set_content(body)
for filename, data, maintype, subtype in (attachments or []):
msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
if not user:
# Dev mode: print instead of sending so the app is testable without an account.
att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or []))
img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items())
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}")
_record(request_id, to_addrs, subject, "dev",
"MAIL_USER not set — printed to console", "skipped (dev mode)")
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)
except Exception as e:
detail = f"SMTP error: {e}"
_record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)")
return False, detail
sent_copy = _save_to_sent(user, password, msg)
_record(request_id, to_addrs, subject, "sent", "", sent_copy)
return True, f"sent to {', '.join(to_addrs)}"
@@ -0,0 +1,236 @@
"""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 mimetypes
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"],
"created_at": req["created_at"],
"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 _record_decision(con, request_id, step, decided_by, decision):
con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)",
(request_id, step, decided_by, decision, db.now()))
def build_summary(con, req, total_steps):
"""Human-readable history: when the request was created and who
approved (or denied) each step, with timestamps."""
lines = [f" Created {req['created_at']} by {req['requester']}"]
for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)):
verb = "Approved" if d["decision"] == "approved" else "DENIED"
lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}")
return "\n".join(lines)
def collect_inline_images(rendered_body):
"""Find {image:name} markers in a rendered body and load those images
from the email image library. Returns a dict for send_mail, or None."""
import re
names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body))
images = {}
for name in names:
ext = os.path.splitext(name)[1].lower()
subtype = db.EMAIL_IMAGE_TYPES.get(ext)
path = os.path.join(db.EMAIL_IMG_DIR, name)
if subtype and os.path.isfile(path):
with open(path, "rb") as f:
images[name] = (f.read(), subtype)
return images or None
def load_attachment(req):
"""Load the request's uploaded file (if any) as a send_mail attachment list."""
if not req["attachment"]:
return None
path = db.attachment_path(req["attachment"])
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return None
# the stored name is "<id>_<original name>"; show the original name in the mail
original = req["attachment"].split("_", 1)[-1]
ctype = mimetypes.guess_type(original)[0] or "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
return [(original, data, maintype, subtype)]
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
con.commit() # release any pending writes before slow mail I/O
v = _vars(wf, req)
body_t = wf["approval_body"]
if "{created_at}" not in body_t: # creation time is mandatory in every mail
body_t = "Request created: {created_at}\n\n" + body_t
attachments = load_attachment(req)
rendered_body = render(body_t, **v)
ok, detail = send_mail(approver,
render(wf["approval_subject"], **v),
rendered_body,
request_id=request_id,
attachments=attachments,
inline_images=collect_inline_images(rendered_body))
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])
summary = build_summary(con, req, len(approver_chain(wf)))
con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection)
v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(),
summary=summary, deny_reason=req["deny_reason"])
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"]
# These are mandatory in the outcome notice: append them if the workflow's
# custom template doesn't include the placeholder.
if new_status == "approved" and "{summary}" not in body_t:
body_t += "\n\nSummary:\n{summary}\n"
if new_status == "denied" and "{deny_reason}" not in body_t:
body_t += "\n\nReason for denial:\n{deny_reason}\n"
if "{created_at}" not in body_t and "{summary}" not in body_t:
# creation time is mandatory in every mail ({summary} already includes it)
body_t = "Request created: {created_at}\n\n" + body_t
rendered_body = render(body_t, **v)
ok, detail = send_mail(participants, render(subj_t, **v), rendered_body,
request_id=req["id"],
inline_images=collect_inline_images(rendered_body))
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, reason=""):
"""Handle a decision at the request's current step.
A denial requires a reason (callers enforce it as a mandatory field;
this is a second line of defense).
Returns (status, request_row_or_None, message).
"""
assert action in ("approve", "deny")
reason = (reason or "").strip()
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":
if not reason:
return "needreason", req, "A reason for denial is required."
# STOP the workflow, notify everyone involved so far.
con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?",
(decided_by, db.now(), reason, req["id"]))
_record_decision(con, req["id"], step + 1, decided_by, "denied")
db.log(con, req["id"], "denied",
f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}")
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
_record_decision(con, req["id"], step + 1, decided_by, "approved")
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])
con.commit()
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,195 @@
/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */
:root, [data-theme="light"] {
--paper: #fafbfc;
--card: #ffffff;
--field: #ffffff;
--hover: #f2f6f8;
--ink: #16232e;
--ink-soft: #5a6b78;
--line: #dfe5ea;
--approve: #0e7c66;
--deny: #b3372f;
--pending: #b7791f;
--focus: #2563a8;
--flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c;
--flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24;
--radius: 8px;
font-size: 16px;
}
[data-theme="dark"] {
--paper: #10161c;
--card: #18212a;
--field: #10161c;
--hover: #1e2a35;
--ink: #e4ebf1;
--ink-soft: #93a5b2;
--line: #2b3945;
--approve: #35b598;
--deny: #e07067;
--pending: #d9a24a;
--focus: #6aa9e0;
--flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3;
--flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa;
}
[data-theme="dark"] body { color-scheme: dark; }
[data-theme="dark"] button, [data-theme="dark"] .btn {
border-color: var(--line); background: #26323e;
}
[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; }
[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; }
[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; }
[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); }
* { 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, input[type=file] {
width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; background: var(--field);
}
input[type=file] { padding: .4rem .5rem; }
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 { 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: var(--hover); }
/* 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: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); }
.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); }
/* 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: var(--hover); 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; } }
}
/* footer */
.footer {
max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem;
font-size: .8rem; color: var(--ink-soft); text-align: center;
border-top: 1px solid var(--line);
}
/* theme toggle */
.theme-toggle {
margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1;
background: transparent; border: 1px solid #4a5b68; color: #b9c6cf;
border-radius: 999px; cursor: pointer;
}
.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; }
.theme-toggle.floating {
position: fixed; top: 1rem; right: 1rem; z-index: 10;
border-color: var(--line); color: var(--ink-soft);
}
.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); }
/* email image library */
.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; }
.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); }
.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; }
.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; }
.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.imgmeta .tag {
background: var(--hover); border: 1px dashed var(--line); border-radius: 5px;
padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal;
}
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Account — MartinhalApprovalFlow{% endblock %}
{% block content %}
<h1>Account</h1>
<p class="sub">Signed in as <b>{{ user.username }}</b>{% if user.email %} · <span class="mono">{{ user.email }}</span>{% endif %}</p>
<div class="card">
<h2 style="margin-top:0">Two-factor authentication (MFA)</h2>
{% if user.mfa_secret %}
<p><span class="stamp approved">enabled</span></p>
<p class="muted">Signing in requires your password and a 6-digit code from your authenticator app.</p>
<form method="post">
<input type="hidden" name="action" value="disable">
<label for="dc">Enter a current code to disable MFA</label>
<input id="dc" type="text" name="code" inputmode="numeric" maxlength="7" required style="max-width:180px">
<button class="btn deny" type="submit">Disable MFA</button>
</form>
{% elif setup_secret %}
<p class="muted">1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app.
If you can't scan, enter the secret manually.</p>
<div id="qr" style="background:#fff;padding:12px;width:fit-content;border-radius:8px"></div>
<p class="mono" style="word-break:break-all">Secret: {{ setup_secret }}</p>
<p class="muted">2 — Enter the 6-digit code the app shows to confirm.</p>
<form method="post" class="rowline">
<input type="hidden" name="action" value="confirm">
<input type="text" name="code" inputmode="numeric" maxlength="7" placeholder="123456" required style="max-width:160px">
<button class="small" type="submit">Confirm and enable</button>
</form>
<form method="post" style="margin-top:.5rem">
<input type="hidden" name="action" value="cancel">
<button class="btn ghost small" type="submit">Cancel setup</button>
</form>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>new QRCode(document.getElementById("qr"), {text: {{ otpauth | tojson }}, width: 180, height: 180});</script>
{% else %}
<p><span class="stamp pending">disabled</span></p>
<p class="muted">Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.</p>
<form method="post">
<input type="hidden" name="action" value="begin">
<button type="submit">Enable MFA</button>
</form>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Workflows &amp; requests — MartinhalApprovalFlow{% endblock %}
{% block content %}
<h1>Workflows &amp; requests</h1>
<p class="sub">Create workflows, watch every request, and open any of them for the full audit trail.</p>
<div class="stats">
<div class="stat"><b>{{ stats.get('pending', 0) }}</b><span>Pending</span></div>
<div class="stat"><b>{{ stats.get('approved', 0) }}</b><span>Approved</span></div>
<div class="stat"><b>{{ stats.get('denied', 0) }}</b><span>Denied</span></div>
</div>
<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 %}
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MartinhalApprovalFlow{% 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') }}">
<script>
// set the theme before first paint to avoid flashing
document.documentElement.dataset.theme =
localStorage.getItem('theme') ||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
}
</script>
</head>
<body>
{% if session.get('user_id') %}
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}">Martinhal<span>ApprovalFlow</span></a>
<nav>
<a href="{{ url_for('index') }}" {% if request.endpoint=='index' %}class="on"{% endif %}>New request</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin') }}" {% if request.endpoint in ('admin','admin_request','workflow_edit') %}class="on"{% endif %}>Workflows &amp; requests</a>
<a href="{{ url_for('mail_status') }}" {% if request.endpoint=='mail_status' %}class="on"{% endif %}>Mail status</a>
<a href="{{ url_for('users') }}" {% if request.endpoint=='users' %}class="on"{% endif %}>Users</a>
{% endif %}
</nav>
<div class="who">
<button class="theme-toggle" type="button" onclick="toggleTheme()" title="Light / dark mode" aria-label="Toggle light or dark mode"></button>
<a href="{{ url_for('account') }}" {% if request.endpoint=='account' %}class="on"{% endif %}>Account</a>
<span class="mono">{{ session['username'] }}</span>
<a href="{{ url_for('logout') }}">Sign out</a>
</div>
</header>
{% else %}
<button class="theme-toggle floating" type="button" onclick="toggleTheme()" title="Light / dark mode" aria-label="Toggle light or dark mode"></button>
{% 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>
<footer class="footer">© 2026 Martinhal IT - Joao Vaz</footer>
</body>
</html>
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Decision — MartinhalApprovalFlow{% 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>
{% if req.attachment %}
<p class="muted">Attachment: <span class="mono">{{ req.attachment.split('_', 1)[-1] }}</span> — sent to you in the approval email.</p>
{% endif %}
<form method="post">
{% if error %}<div class="flash error">{{ error }}</div>{% endif %}
<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 }}">
{% if action == 'deny' %}
<label for="rr">Reason for denial <small>(required — included in the notice everyone receives)</small></label>
<textarea id="rr" name="reason" required></textarea>
{% endif %}
<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,50 @@
{% extends "base.html" %}
{% block title %}New request — MartinhalApprovalFlow{% 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" enctype="multipart/form-data">
<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="re">Request's e-mail <small>(required — this address receives the outcome)</small></label>
<input id="re" type="email" name="request_email" value="{{ user_email }}" required>
<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>
<label for="f">Attachment <small>(optional, up to 15 MB — included in every approval mail)</small></label>
<input id="f" type="file" name="attachment">
<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,26 @@
{% extends "base.html" %}
{% block title %}Sign in — MartinhalApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card">
<div class="brand-lg">Martinhal<span>ApprovalFlow</span></div>
{% if mfa_stage %}
<p class="sub">Two-factor authentication is enabled for <b>{{ username }}</b>. Enter the 6-digit code from your authenticator app.</p>
<form method="post" action="{{ url_for('login_mfa') }}">
<label for="c">Verification code</label>
<input id="c" type="text" name="code" inputmode="numeric" pattern="[0-9 ]*" maxlength="7"
autocomplete="one-time-code" autofocus required>
<button type="submit">Verify and sign in</button>
</form>
<p class="muted" style="margin-top:1rem"><a href="{{ url_for('login') }}">&larr; Back to sign in</a></p>
{% else %}
<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>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Mail status — MartinhalApprovalFlow{% endblock %}
{% block content %}
<h1>Mail status</h1>
<p class="sub">Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.</p>
{% if not configured %}
<div class="flash error">Mail account not configured — <span class="mono">MAIL_USER</span> is empty, so messages are printed to the server console (dev mode) instead of being sent.</div>
{% endif %}
<div class="stats">
<div class="stat"><b>{{ stats.get('sent', 0) }}</b><span>Sent</span></div>
<div class="stat"><b>{{ stats.get('error', 0) }}</b><span>Errors</span></div>
<div class="stat"><b>{{ copy_fails }}</b><span>Sent-copy failures</span></div>
<div class="stat"><b>{{ stats.get('dev', 0) }}</b><span>Dev mode</span></div>
</div>
<div class="card">
<table>
<tr><th>When</th><th>To</th><th>Subject</th><th>Request</th><th>Delivery</th><th>Sent folder</th></tr>
{% for m in rows %}
<tr>
<td class="mono muted">{{ m.at }}</td>
<td class="mono">{{ m.recipients }}</td>
<td>{{ m.subject }}
{% if m.detail %}<br><span class="muted">{{ m.detail }}</span>{% endif %}</td>
<td class="mono">{% if m.request_id %}<a href="{{ url_for('admin_request', rid=m.request_id) }}">#{{ m.request_id }}</a>{% else %}—{% endif %}</td>
<td>
{% if m.status == 'sent' %}<span class="stamp approved">sent</span>
{% elif m.status == 'error' %}<span class="stamp denied">error</span>
{% else %}<span class="stamp pending">dev</span>{% endif %}
</td>
<td>
{% if m.sent_copy.startswith('saved') %}<span class="chip">{{ m.sent_copy }}</span>
{% elif m.sent_copy.startswith('failed') %}<span class="stamp denied">copy failed</span><br><span class="muted">{{ m.sent_copy }}</span>
{% else %}<span class="muted">{{ m.sent_copy }}</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No mail has been sent yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All requests</a></p>
<div class="rowline">
<h1>Request #{{ req.id }}</h1>
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</div>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · 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.attachment %}
<p><b>Attachment:</b>
<a class="mono" href="{{ url_for('request_attachment', rid=req.id) }}">{{ req.attachment.split('_', 1)[-1] }}</a>
<span class="muted">(included in every approval mail)</span></p>
{% endif %}
{% 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 == 'denied' and req.deny_reason %}
<p><b>Reason for denial:</b></p>
<div class="pre">{{ req.deny_reason }}</div>
{% endif %}
{% if req.status == 'pending' %}
<form method="post">
<div class="rowline">
<button class="btn approve small" name="action" value="approve">Approve this step</button>
<span class="muted">Same effect as the current approver's email links.</span>
</div>
</form>
<form method="post" style="margin-top:.8rem">
<label for="rr" style="margin-top:0">Reason for denial <small>(required to deny)</small></label>
<textarea id="rr" name="reason" required style="min-height:70px"></textarea>
<button class="btn deny small" name="action" value="deny" style="margin-top:.6rem">Deny &amp; stop workflow</button>
</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>
<form method="post" action="{{ url_for('send_request_log', rid=req.id) }}" class="rowline" style="margin-top:1rem">
<input type="email" name="email" placeholder="name@example.com" required style="max-width:280px">
<button class="small" type="submit">Email this full log</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Users — MartinhalApprovalFlow{% 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. Users enable MFA themselves on their Account page; you can disable it here if someone loses their device.</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.mfa_secret %}<span class="chip">MFA on</span>{% endif %}
{% if u.email %}<span class="mono muted">{{ u.email }}</span>{% endif %}
<span style="flex:1"></span>
{% if u.mfa_secret %}
<form method="post" class="inline" onsubmit="return confirm('Disable MFA for {{ u.username }}? They can re-enable it from their Account page.')">
<input type="hidden" name="action" value="mfa_off">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button class="btn ghost small">Disable MFA</button>
</form>
{% endif %}
{% 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 %}
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All workflows</a></p>
<h1>{{ 'Edit workflow' if wf else 'New workflow' }}</h1>
<p class="sub">Each workflow has its own allowed sender domain, its own approver, and its own email wording.</p>
<form method="post">
<div class="card">
<h2 style="margin-top:0">Basics</h2>
<div class="grid2">
<div>
<label for="n">Name</label>
<input id="n" type="text" name="name" value="{{ wf.name if wf else '' }}" required>
</div>
<div>
<label for="de">Description <small>(shown to users on the main page)</small></label>
<input id="de" type="text" name="description" value="{{ wf.description if wf else '' }}">
</div>
</div>
<label for="ap">Approval chain <small>(one email per line, in order — the request goes to the first person; each approval sends it to the next; any denial stops the workflow)</small></label>
<textarea id="ap" name="approvers" class="tpl" required
placeholder="team-lead@example.com&#10;manager@example.com&#10;director@example.com">{% if wf %}{{ '\n'.join(wf.approvers | fromjson) }}{% endif %}</textarea>
<label class="check"><input type="checkbox" name="active" {% if not wf or wf.active %}checked{% endif %}> Active — accept new requests</label>
</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} {created_at} {summary} {deny_reason} {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>
<div class="card">
<h2 style="margin-top:0">Images for email bodies</h2>
<p class="muted">These images can be used in the <b>approval request body</b>, <b>approved notice body</b> and
<b>denied notice body</b> above. Copy an image's tag into the text where the image should appear —
it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.</p>
{% if images %}
<div class="imggrid">
{% for name in images %}
<div class="imgcard">
<img src="{{ url_for('email_image', name=name) }}" alt="{{ name }}">
<div class="imgmeta">
<span class="mono" title="{{ name }}">{{ name }}</span>
<code class="mono tag" onclick="navigator.clipboard && navigator.clipboard.writeText(this.textContent)"
title="Click to copy">{{ '{image:' + name + '}' }}</code>
<form method="post" action="{{ url_for('email_image_delete') }}"
onsubmit="return confirm('Delete {{ name }}? Emails using its tag will show plain text instead.')">
<input type="hidden" name="name" value="{{ name }}">
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn ghost small" type="submit">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">No images uploaded yet.</p>
{% endif %}
<form method="post" action="{{ url_for('email_image_upload') }}" enctype="multipart/form-data" class="rowline" style="margin-top:1rem">
<input type="hidden" name="next" value="{{ request.path }}">
<input type="file" name="image" accept=".png,.jpg,.jpeg,.gif" required>
<button class="small" type="submit">Upload image</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard
library. Compatible with Google Authenticator, Authy, 1Password, etc."""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from urllib.parse import quote
ISSUER = "MartinhalApprovalFlow"
def new_secret() -> str:
"""A new base32 secret to enroll in an authenticator app."""
return base64.b32encode(secrets.token_bytes(20)).decode()
def _code_at(secret: str, counter: int) -> str:
key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True)
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 15
number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000
return f"{number:06d}"
def verify(secret: str, code: str) -> bool:
"""Check a 6-digit code, allowing one 30s step of clock drift each way."""
code = (code or "").strip().replace(" ", "")
if not (secret and code.isdigit() and len(code) == 6):
return False
counter = int(time.time() // 30)
return any(hmac.compare_digest(_code_at(secret, counter + drift), code)
for drift in (-1, 0, 1))
def otpauth_uri(username: str, secret: str) -> str:
"""URI encoded as a QR code for authenticator apps."""
return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}"
f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30")