This commit is contained in:
jpmvaz
2026-09-13 20:03:24 +01:00
commit 0ebb8e7f4b
270 changed files with 23789 additions and 0 deletions
+96
View File
@@ -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 %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Workflows &amp; requests — MartinhalApprovalFlow{% endblock %}
{% block content %}
<h1>Workflows &amp; requests</h1>
<p class="sub">Create workflows, watch every request, and open any of them for the full audit trail.</p>
<div class="stats">
<div class="stat"><b>{{ stats.get('pending', 0) }}</b><span>Pending</span></div>
<div class="stat"><b>{{ stats.get('approved', 0) }}</b><span>Approved</span></div>
<div class="stat"><b>{{ stats.get('denied', 0) }}</b><span>Denied</span></div>
</div>
<h2>Workflows</h2>
<div class="card">
<table>
<tr><th>Name</th><th>Approval chain</th><th>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 %}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MartinhalApprovalFlow{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>
// set the theme before first paint to avoid flashing
document.documentElement.dataset.theme =
localStorage.getItem('theme') ||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
}
</script>
</head>
<body>
{% if session.get('user_id') %}
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}">Martinhal<span>ApprovalFlow</span></a>
<nav>
<a href="{{ url_for('index') }}" {% if request.endpoint=='index' %}class="on"{% endif %}>New request</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin') }}" {% if request.endpoint in ('admin','admin_request','workflow_edit') %}class="on"{% endif %}>Workflows &amp; requests</a>
<a href="{{ url_for('mail_status') }}" {% if request.endpoint=='mail_status' %}class="on"{% endif %}>Mail status</a>
<a href="{{ url_for('users') }}" {% if request.endpoint=='users' %}class="on"{% endif %}>Users</a>
{% endif %}
</nav>
<div class="who">
<button class="theme-toggle" type="button" onclick="toggleTheme()" title="Light / dark mode" aria-label="Toggle light or dark mode"></button>
<a href="{{ url_for('account') }}" {% if request.endpoint=='account' %}class="on"{% endif %}>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.3</footer>
</body>
</html>
+65
View File
@@ -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 %}
+50
View File
@@ -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 %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Sign in — MartinhalApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card">
<div class="brand-lg">Martinhal<span>ApprovalFlow</span></div>
{% if mfa_stage %}
<p class="sub">Two-factor authentication is enabled for <b>{{ username }}</b>. Enter the 6-digit code from your authenticator app.</p>
<form method="post" action="{{ url_for('login_mfa') }}">
<label for="c">Verification code</label>
<input id="c" type="text" name="code" inputmode="numeric" pattern="[0-9 ]*" maxlength="7"
autocomplete="one-time-code" autofocus required>
<button type="submit">Verify and sign in</button>
</form>
<p class="muted" style="margin-top:1rem"><a href="{{ url_for('login') }}">&larr; Back to sign in</a></p>
{% else %}
<p class="sub">Sign in to send and track requests.</p>
<form method="post">
<label for="u">Username</label>
<input id="u" type="text" name="username" autofocus required>
<label for="p">Password</label>
<input id="p" type="password" name="password" required>
<button type="submit">Sign in</button>
</form>
{% endif %}
</div>
{% endblock %}
+44
View File
@@ -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 %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All requests</a></p>
<div class="rowline">
<h1>Request #{{ req.id }}</h1>
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</div>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · received {{ req.created_at }}</p>
<div class="card">
<p><b>From:</b> <span class="mono">{{ req.requester }}</span><br>
<b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
{% if 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 &amp; stop workflow</button>
</form>
{% endif %}
</div>
<h2>Approval chain</h2>
<div class="card">
<ul class="ledger">
{% for a in chain %}
<li>
<span class="t mono">step {{ loop.index }} / {{ chain | length }}</span>
<span class="e mono">{{ a }}</span>
<span>
{% if req.status == 'denied' and loop.index0 == req.current_step %}<span class="stamp denied">denied here</span>
{% elif loop.index0 < req.current_step or req.status == 'approved' %}<span class="stamp approved">approved</span>
{% elif loop.index0 == req.current_step and req.status == 'pending' %}<span class="stamp pending">waiting</span>
{% elif req.status == 'denied' %}<span class="muted">never reached — workflow stopped</span>
{% else %}<span class="muted">not yet reached</span>{% endif %}
</span>
</li>
{% endfor %}
</ul>
</div>
<h2>People involved so far</h2>
<div class="card">
{% for p in participants %}<span class="chip">{{ p }}</span> {% else %}<span class="muted">None recorded.</span>{% endfor %}
</div>
<h2>Full log</h2>
<div class="card">
<ul class="ledger">
{% for l in logs %}
<li><span class="t">{{ l.at }}</span><span class="e">{{ l.event }}</span><span>{{ l.detail }}</span></li>
{% endfor %}
</ul>
<form method="post" action="{{ url_for('send_request_log', rid=req.id) }}" class="rowline" style="margin-top:1rem">
<input type="email" name="email" placeholder="name@example.com" required style="max-width:280px">
<button class="small" type="submit">Email this full log</button>
</form>
</div>
{% endblock %}
+76
View File
@@ -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 %}
+99
View File
@@ -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') }}">&larr; All workflows</a></p>
<h1>{{ 'Edit workflow' if wf else 'New workflow' }}</h1>
<p class="sub">Each workflow has its own allowed sender domain, its own approver, and its own email wording.</p>
<form method="post">
<div class="card">
<h2 style="margin-top:0">Basics</h2>
<div class="grid2">
<div>
<label for="n">Name</label>
<input id="n" type="text" name="name" value="{{ wf.name if wf else '' }}" required>
</div>
<div>
<label for="de">Description <small>(shown to users on the main page)</small></label>
<input id="de" type="text" name="description" value="{{ wf.description if wf else '' }}">
</div>
</div>
<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&#10;manager@example.com&#10;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&#10;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 %}