v_9.3
This commit is contained in:
@@ -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.3</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 %}
|
||||
Reference in New Issue
Block a user