v_9.3
This commit is contained in:
Binary file not shown.
@@ -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"]
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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 or more uploaded files (15 MB combined). They are attached to **every approval mail** sent along the chain, and admins can download each from the request page. Files are stored next to the database in `data/attachments/`.
|
||||
- **Knowledge chain.** Next to each workflow's approval chain there is a knowledge chain: addresses that never approve anything but are informed by e-mail when a request is created and on every step approval, denial, or final approval. They are not counted as "involved" and don't receive the participant outcome notice — only their own FYI messages.
|
||||
- **Configure profile.** Every user has a profile page (top-right corner): first name, last name, unit location, e-mail, change password, and MFA. The profile e-mail pre-fills the mandatory "Request's e-mail" field when submitting a request.
|
||||
- **MFA.** Any user can enable two-factor authentication from their Configure profile 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/`.
|
||||
|
||||
> **If a value in `.env` contains a `$`** (common in passwords), escape it as `$$` — e.g. `MAIL_PASSWORD=xK9$$vh4pQ2`. Docker Compose otherwise treats `$word` as a variable and silently replaces it with a blank string, printing a warning like `The "word" variable is not set`.
|
||||
|
||||
## 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}`.
|
||||
|
||||
## Upgrading an existing instance (no data loss)
|
||||
|
||||
All schema changes are applied automatically at boot by the built-in migrations —
|
||||
the SQLite database and uploaded files live in `./data/`, which is a volume and is
|
||||
never touched by rebuilding the image.
|
||||
|
||||
```bash
|
||||
cd /path/to/approvalflow
|
||||
docker compose down # stop the app (data stays in ./data)
|
||||
cp -r data data.backup-$(date +%F) # safety backup of DB + attachments
|
||||
# replace the code with the new version, KEEPING .env and data/:
|
||||
# unzip -o approvalflow.zip -d .. (or copy the new files over the old ones)
|
||||
docker compose up -d --build # rebuild image, start; migrations run at boot
|
||||
docker compose logs | grep migrate # optional: see what was migrated
|
||||
```
|
||||
|
||||
Roll back = stop, restore `data.backup-…` to `data/`, start the previous image.
|
||||
|
||||
## 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,579 @@
|
||||
"""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 files are too large — the combined 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 == "profile":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
if email and "@" not in email:
|
||||
flash("That e-mail address doesn't look valid.", "error")
|
||||
else:
|
||||
g.db.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, unit_location=?, email=? WHERE id=?",
|
||||
(request.form.get("first_name", "").strip(),
|
||||
request.form.get("last_name", "").strip(),
|
||||
request.form.get("unit_location", "").strip(),
|
||||
email, user["id"]))
|
||||
g.db.commit()
|
||||
flash("Profile saved.", "ok")
|
||||
return redirect(url_for("account"))
|
||||
if act == "password":
|
||||
if not check_password_hash(user["password_hash"], request.form.get("current_password", "")):
|
||||
flash("Your current password is wrong.", "error")
|
||||
elif not request.form.get("new_password"):
|
||||
flash("The new password can't be empty.", "error")
|
||||
elif request.form.get("new_password") != request.form.get("confirm_password"):
|
||||
flash("The new passwords don't match.", "error")
|
||||
else:
|
||||
g.db.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||
(generate_password_hash(request.form["new_password"]), user["id"]))
|
||||
g.db.commit()
|
||||
flash("Password changed.", "ok")
|
||||
return redirect(url_for("account"))
|
||||
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()
|
||||
uploads = [(f, f.read()) for f in request.files.getlist("attachments")
|
||||
if f and f.filename]
|
||||
total = sum(len(data) for _, data in uploads)
|
||||
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 total > db.MAX_ATTACHMENT_BYTES:
|
||||
flash("The uploaded files are too large — the combined 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 uploads:
|
||||
os.makedirs(db.ATTACH_DIR, exist_ok=True)
|
||||
entries = []
|
||||
for i, (f, data) in enumerate(uploads, 1):
|
||||
name = secure_filename(f.filename) or f"attachment-{i}"
|
||||
stored = f"{rid}_{i}_{name}"
|
||||
with open(db.attachment_path(stored), "wb") as fh:
|
||||
fh.write(data)
|
||||
entries.append({"stored": stored, "name": name})
|
||||
g.db.execute("UPDATE requests SET attachment=? WHERE id=?",
|
||||
(json.dumps(entries), rid))
|
||||
db.log(g.db, rid, "attachments saved",
|
||||
", ".join(f"'{e['name']}'" for e in entries) +
|
||||
f" ({total} bytes total) — included in every approval mail")
|
||||
g.db.commit()
|
||||
pipeline.send_approval_request(rid)
|
||||
pipeline.notify_knowledge(rid, f"New request created by {requester}")
|
||||
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,
|
||||
attachments=pipeline.attachment_list(req))
|
||||
|
||||
|
||||
@app.route("/admin/request/<int:rid>/attachment/<int:idx>")
|
||||
@admin_required
|
||||
def request_attachment(rid, idx):
|
||||
req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone()
|
||||
if req is None:
|
||||
abort(404)
|
||||
entries = pipeline.attachment_list(req)
|
||||
if idx < 0 or idx >= len(entries):
|
||||
abort(404)
|
||||
path = db.attachment_path(entries[idx]["stored"])
|
||||
if not os.path.exists(path):
|
||||
abort(404)
|
||||
return send_file(path, as_attachment=True, download_name=entries[idx]["name"])
|
||||
|
||||
|
||||
@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, w.knowledge 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"Knowledge chain: {', '.join(pipeline.knowledge_chain(req)) or '(none)'}",
|
||||
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()]
|
||||
knowledge = [a.strip() for a in f.get("knowledge", "").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, knowledge, 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),
|
||||
json.dumps(knowledge), 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=?, knowledge=?,"
|
||||
" 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),
|
||||
json.dumps(knowledge), 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,249 @@
|
||||
"""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 '',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
unit_location TEXT NOT NULL 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
|
||||
knowledge TEXT NOT NULL DEFAULT '[]', -- JSON list: informed of every event, never approve
|
||||
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 '[]', -- JSON list of {"stored": ..., "name": ...}
|
||||
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.")
|
||||
for col in ("first_name", "last_name", "unit_location"):
|
||||
if ucols and col not in ucols:
|
||||
con.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
|
||||
print(f"[migrate] Added {col} to users.")
|
||||
wcols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")}
|
||||
if wcols and "knowledge" not in wcols:
|
||||
con.execute("ALTER TABLE workflows ADD COLUMN knowledge TEXT NOT NULL DEFAULT '[]'")
|
||||
print("[migrate] Added knowledge chain to workflows.")
|
||||
# single-attachment values -> JSON list format
|
||||
if rcols and "attachment" in rcols:
|
||||
for row in con.execute("SELECT id, attachment FROM requests "
|
||||
"WHERE attachment != '' AND attachment NOT LIKE '[%'").fetchall():
|
||||
stored = row["attachment"]
|
||||
entry = [{"stored": stored, "name": stored.split("_", 1)[-1]}]
|
||||
con.execute("UPDATE requests SET attachment=? WHERE id=?",
|
||||
(json.dumps(entry), row["id"]))
|
||||
print(f"[migrate] Converted attachment of request #{row['id']} to list format.")
|
||||
con.execute("UPDATE requests SET attachment='[]' WHERE attachment=''")
|
||||
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,291 @@
|
||||
"""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 attachment_list(req):
|
||||
"""Parse the request's attachment column into [{"stored":…, "name":…}, …]."""
|
||||
try:
|
||||
return json.loads(req["attachment"] or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def load_attachments(req):
|
||||
"""Load every uploaded file of the request as send_mail attachments."""
|
||||
out = []
|
||||
for entry in attachment_list(req):
|
||||
path = db.attachment_path(entry["stored"])
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
ctype = mimetypes.guess_type(entry["name"])[0] or "application/octet-stream"
|
||||
maintype, subtype = ctype.split("/", 1)
|
||||
out.append((entry["name"], data, maintype, subtype))
|
||||
return out or None
|
||||
|
||||
|
||||
def knowledge_chain(wf):
|
||||
try:
|
||||
chain = json.loads(wf["knowledge"])
|
||||
except Exception:
|
||||
chain = []
|
||||
return [a.strip() for a in chain if a and a.strip()]
|
||||
|
||||
|
||||
def notify_knowledge(request_id, event):
|
||||
"""Inform the workflow's knowledge chain of an event. These addresses are
|
||||
never part of the approval chain — they only receive FYI messages."""
|
||||
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()
|
||||
watchers = knowledge_chain(wf)
|
||||
if not watchers:
|
||||
return
|
||||
chain = approver_chain(wf)
|
||||
subject = f"[{wf['name']}] FYI — request #{req['id']}: {event}"
|
||||
body = (
|
||||
f"For your information (you are on the knowledge chain of this workflow —\n"
|
||||
f"no action is needed from you):\n\n"
|
||||
f"{event}\n\n"
|
||||
f"Workflow: {wf['name']}\n"
|
||||
f"From: {req['requester']}\n"
|
||||
f"Created: {req['created_at']}\n"
|
||||
f"Subject: {req['subject']}\n"
|
||||
f"Status: {req['status'].upper()}"
|
||||
f" (step {min(req['current_step'], max(len(chain)-1, 0)) + 1} of {len(chain)})\n\n"
|
||||
f"Message:\n{req['body']}\n"
|
||||
)
|
||||
if req["deny_reason"]:
|
||||
body += f"\nReason for denial:\n{req['deny_reason']}\n"
|
||||
ok, detail = send_mail(watchers, subject, body, request_id=request_id)
|
||||
db.log(con, request_id,
|
||||
"knowledge chain informed" if ok else "mail error",
|
||||
f"'{event}' to {', '.join(watchers)} — {detail}")
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
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_attachments(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()
|
||||
notify_knowledge(req["id"], f"DENIED at step {step + 1}/{len(chain)} by {decided_by}")
|
||||
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()
|
||||
notify_knowledge(req["id"],
|
||||
f"FULLY APPROVED — final approval ({len(chain)}/{len(chain)}) by {decided_by}")
|
||||
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()
|
||||
notify_knowledge(req["id"], f"Step {step + 1}/{len(chain)} approved by {decided_by} — "
|
||||
f"now waiting on {chain[step + 1]}")
|
||||
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,96 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Configure profile — MartinhalApprovalFlow{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Configure profile</h1>
|
||||
<p class="sub">Signed in as <b>{{ user.username }}</b></p>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Profile</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="profile">
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label for="fn">First name</label>
|
||||
<input id="fn" type="text" name="first_name" value="{{ user.first_name }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ln">Last name</label>
|
||||
<input id="ln" type="text" name="last_name" value="{{ user.last_name }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ul">Unit location</label>
|
||||
<input id="ul" type="text" name="unit_location" value="{{ user.unit_location }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="em">E-mail <small>(pre-fills “Request's e-mail” and receives outcome notices)</small></label>
|
||||
<input id="em" type="email" name="email" value="{{ user.email }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Change password</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label for="cp">Current password</label>
|
||||
<input id="cp" type="password" name="current_password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<label for="np">New password</label>
|
||||
<input id="np" type="password" name="new_password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="np2">Confirm new password</label>
|
||||
<input id="np2" type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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">Activate MFA</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Workflows & requests — MartinhalApprovalFlow{% 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>
|
||||
|
||||
<h2>Workflows</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Approval chain</th><th>Knowledge 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 class="mono">{% for a in w.knowledge | fromjson %}{{ a }}{% if not loop.last %}<br>{% endif %}{% else %}<span class="muted">—</span>{% 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>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 & 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 %}>Configure profile</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 - Version 9.2</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
{% 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>
|
||||
{% set atts = req.attachment | fromjson %}
|
||||
{% if atts %}
|
||||
<p class="muted">Attachments sent to you in the approval email:
|
||||
{% for a in atts %}<span class="mono">{{ a.name }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</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">Attachments <small>(optional, one or more files, 15 MB combined — included in every approval mail)</small></label>
|
||||
<input id="f" type="file" name="attachments" multiple>
|
||||
<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') }}">← 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,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% 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> · 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 attachments %}
|
||||
<p><b>Attachments</b> <span class="muted">(included in every approval mail)</span>:<br>
|
||||
{% for att in attachments %}
|
||||
<a class="mono" href="{{ url_for('request_attachment', rid=req.id, idx=loop.index0) }}">{{ att.name }}</a>{% if not loop.last %} · {% endif %}
|
||||
{% endfor %}
|
||||
</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 & 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,76 @@
|
||||
{% 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 fill in their own details (name, unit, e-mail, MFA) on their Configure profile page; you can disable MFA 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.first_name or u.last_name %}<span class="muted">{{ u.first_name }} {{ u.last_name }}</span>{% endif %}
|
||||
{% if u.unit_location %}<span class="chip">{{ u.unit_location }}</span>{% endif %}
|
||||
{% 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 Configure profile 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,99 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% 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>
|
||||
<div class="grid2">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label for="kn">Knowledge chain <small>(one email per line — these people never approve, but are informed by e-mail when a request is created and every time an approval, denial or final decision happens)</small></label>
|
||||
<textarea id="kn" name="knowledge" class="tpl"
|
||||
placeholder="team-inbox@example.com finance@example.com">{% if wf %}{{ '\n'.join(wf.knowledge | fromjson) }}{% endif %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<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")
|
||||
Reference in New Issue
Block a user