Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor
- FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing
This commit is contained in:
32
web/templates/base.html
Normal file
32
web/templates/base.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}ULPgrammer{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% if user is defined %}
|
||||
<nav>
|
||||
<a href="/dashboard" class="nav-brand">ULPgrammer</a>
|
||||
<span class="nav-links">
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/config/keywords">Config</a>
|
||||
{% if user.role == 'superadmin' %}
|
||||
<a href="/users">Users</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit" class="btn-link">Logout ({{ user.username }})</button>
|
||||
</form>
|
||||
</span>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
145
web/templates/config.html
Normal file
145
web/templates/config.html
Normal file
@@ -0,0 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Config — ULPgrammer{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Configuration</h2>
|
||||
|
||||
<div class="tabs">
|
||||
<a href="/config/keywords" class="tab {% if active_tab == 'keywords' %}active{% endif %}">Keywords</a>
|
||||
<a href="/config/channels" class="tab {% if active_tab == 'channels' %}active{% endif %}">Channels</a>
|
||||
</div>
|
||||
|
||||
{% if active_tab == 'keywords' %}
|
||||
<section>
|
||||
<h3>Keyword Groups</h3>
|
||||
<p class="hint">Each group can have multiple regex patterns. Patterns containing <code>@</code> trigger CRITICAL on matching email usernames.</p>
|
||||
|
||||
<form id="kw-form">
|
||||
<div id="groups-container">
|
||||
{% for g in groups %}
|
||||
<fieldset class="group-fieldset">
|
||||
<legend>
|
||||
<input name="group_name" value="{{ g.name }}" placeholder="Group name" required>
|
||||
<button type="button" class="btn-danger" onclick="this.closest('fieldset').remove()">Remove group</button>
|
||||
</legend>
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<table class="patterns-table">
|
||||
<thead><tr><th>Regex</th><th>Label</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in g.patterns %}
|
||||
<tr>
|
||||
<td><input name="regex" value="{{ p.regex }}" required></td>
|
||||
<td><input name="label" value="{{ p.label }}"></td>
|
||||
<td><button type="button" onclick="this.closest('tr').remove()">✕</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn-add" onclick="addPatternRow(this)">+ Add pattern</button>
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" onclick="addGroup()">+ Add group</button>
|
||||
<button type="submit" class="btn-primary">Save keywords</button>
|
||||
<span id="kw-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% elif active_tab == 'channels' %}
|
||||
<section>
|
||||
<h3>Watched Channels</h3>
|
||||
<p class="hint">Username (without @) or numeric channel ID (e.g. <code>-1002748707556</code>).</p>
|
||||
|
||||
<form id="ch-form">
|
||||
<ul id="channels-list">
|
||||
{% for ch in channels %}
|
||||
<li>
|
||||
<input name="channel" value="{{ ch }}" required>
|
||||
<button type="button" onclick="this.closest('li').remove()">✕</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button type="button" onclick="addChannel()">+ Add channel</button>
|
||||
<button type="submit" class="btn-primary">Save channels</button>
|
||||
<span id="ch-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function addPatternRow(btn) {
|
||||
const tbody = btn.previousElementSibling.querySelector("tbody");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = '<td><input name="regex" required></td><td><input name="label"></td><td><button type="button" onclick="this.closest(\'tr\').remove()">✕</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
const id = "group_" + Date.now();
|
||||
const fs = document.createElement("fieldset");
|
||||
fs.className = "group-fieldset";
|
||||
fs.innerHTML = `
|
||||
<legend>
|
||||
<input name="group_name" placeholder="Group name" required>
|
||||
<button type="button" class="btn-danger" onclick="this.closest('fieldset').remove()">Remove group</button>
|
||||
</legend>
|
||||
<input type="hidden" name="group_id" value="${id}">
|
||||
<table class="patterns-table">
|
||||
<thead><tr><th>Regex</th><th>Label</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<button type="button" class="btn-add" onclick="addPatternRow(this)">+ Add pattern</button>
|
||||
`;
|
||||
document.getElementById("groups-container").appendChild(fs);
|
||||
}
|
||||
|
||||
function addChannel() {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = '<input name="channel" required><button type="button" onclick="this.closest(\'li\').remove()">✕</button>';
|
||||
document.getElementById("channels-list").appendChild(li);
|
||||
}
|
||||
|
||||
// Keyword form submit
|
||||
document.getElementById("kw-form")?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const groups = collectGroups();
|
||||
const res = await fetch("/config/keywords", {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({groups}),
|
||||
});
|
||||
document.getElementById("kw-status").textContent = res.ok ? "Saved." : "Error: " + await res.text();
|
||||
});
|
||||
|
||||
// Channel form submit
|
||||
document.getElementById("ch-form")?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const inputs = document.querySelectorAll("#channels-list input[name=channel]");
|
||||
const channels = [...inputs].map(i => {
|
||||
const v = i.value.trim();
|
||||
return /^-?\d+$/.test(v) ? parseInt(v, 10) : v;
|
||||
});
|
||||
const res = await fetch("/config/channels", {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({channels}),
|
||||
});
|
||||
document.getElementById("ch-status").textContent = res.ok ? "Saved." : "Error: " + await res.text();
|
||||
});
|
||||
|
||||
function collectGroups() {
|
||||
const fieldsets = document.querySelectorAll(".group-fieldset");
|
||||
return [...fieldsets].map(fs => {
|
||||
const id = fs.querySelector("input[name=group_id]").value;
|
||||
const name = fs.querySelector("input[name=group_name]").value;
|
||||
const rows = fs.querySelectorAll("tbody tr");
|
||||
const patterns = [...rows].map(tr => ({
|
||||
regex: tr.querySelector("input[name=regex]").value,
|
||||
label: tr.querySelector("input[name=label]").value,
|
||||
}));
|
||||
return {id, name, patterns};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
64
web/templates/dashboard.html
Normal file
64
web/templates/dashboard.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — ULPgrammer{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="stats-bar"
|
||||
hx-get="/api/stats"
|
||||
hx-trigger="every 10s"
|
||||
hx-swap="outerHTML">
|
||||
<span class="stat critical">🔴 CRITICAL: {{ counts.CRITICAL }}</span>
|
||||
<span class="stat high">🟠 HIGH: {{ counts.HIGH }}</span>
|
||||
<span class="stat medium">🟡 MEDIUM: {{ counts.MEDIUM }}</span>
|
||||
<span class="stat low">🟢 LOW: {{ counts.LOW }}</span>
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="groups-bar">
|
||||
<strong>Groups:</strong>
|
||||
{% for g in groups %}
|
||||
<a href="/dashboard/groups/{{ g.id }}" class="group-pill">{{ g.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Live feed</h2>
|
||||
<div id="hit-feed"
|
||||
hx-ext="sse"
|
||||
sse-connect="/api/stream"
|
||||
sse-swap="hit"
|
||||
hx-swap="afterbegin">
|
||||
{% for hit in hits %}
|
||||
<div class="hit-card sev-{{ hit.severity|lower }}">
|
||||
<span class="sev-badge">{{ hit.severity }}</span>
|
||||
<code class="raw">{{ hit.raw }}</code>
|
||||
<span class="meta">{{ hit.source }} / {{ hit.filename }} — {{ hit.timestamp }}</span>
|
||||
{% if hit.reasons %}
|
||||
<ul class="reasons">{% for r in hit.reasons %}<li>{{ r }}</li>{% endfor %}</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// SSE: inject new hit cards into #hit-feed
|
||||
document.body.addEventListener("htmx:sseMessage", function(e) {
|
||||
if (e.detail.type !== "hit") return;
|
||||
const data = JSON.parse(e.detail.data);
|
||||
const feed = document.getElementById("hit-feed");
|
||||
const card = document.createElement("div");
|
||||
card.className = "hit-card sev-" + data.severity.toLowerCase();
|
||||
card.innerHTML = `
|
||||
<span class="sev-badge">${data.severity}</span>
|
||||
<code class="raw">${escHtml(data.raw)}</code>
|
||||
<span class="meta">${escHtml(data.source)} / ${escHtml(data.filename)}</span>
|
||||
<ul class="reasons">${data.reasons.map(r => `<li>${escHtml(r)}</li>`).join("")}</ul>
|
||||
`;
|
||||
feed.prepend(card);
|
||||
});
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
37
web/templates/group_detail.html
Normal file
37
web/templates/group_detail.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ group.name }} — ULPgrammer{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>{{ group.name }}</h2>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span class="stat critical">🔴 CRITICAL: {{ counts.CRITICAL }}</span>
|
||||
<span class="stat high">🟠 HIGH: {{ counts.HIGH }}</span>
|
||||
<span class="stat medium">🟡 MEDIUM: {{ counts.MEDIUM }}</span>
|
||||
<span class="stat low">🟢 LOW: {{ counts.LOW }}</span>
|
||||
</div>
|
||||
|
||||
<details class="patterns-list">
|
||||
<summary>Patterns ({{ group.patterns|length }})</summary>
|
||||
<ul>
|
||||
{% for p in group.patterns %}
|
||||
<li><code>{{ p.regex }}</code> — {{ p.label }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<h3>Recent hits</h3>
|
||||
{% for hit in hits %}
|
||||
<div class="hit-card sev-{{ hit.severity|lower }}">
|
||||
<span class="sev-badge">{{ hit.severity }}</span>
|
||||
<code class="raw">{{ hit.raw }}</code>
|
||||
<span class="meta">{{ hit.source }} / {{ hit.filename }} — {{ hit.timestamp }}</span>
|
||||
{% if hit.reasons %}
|
||||
<ul class="reasons">{% for r in hit.reasons %}<li>{{ r }}</li>{% endfor %}</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">No hits yet for this group.</p>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
21
web/templates/login.html
Normal file
21
web/templates/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login — ULPgrammer</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-box">
|
||||
<h1>ULPgrammer</h1>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label>Username<input type="text" name="username" autofocus required></label>
|
||||
<label>Password<input type="password" name="password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
129
web/templates/users.html
Normal file
129
web/templates/users.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users — ULPgrammer{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Users</h2>
|
||||
|
||||
<button class="btn-primary" onclick="document.getElementById('create-modal').showModal()">+ New user</button>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr id="user-{{ u.id }}">
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.role }}</td>
|
||||
<td>{{ u.created_at[:10] }}</td>
|
||||
<td>{{ "Yes" if u.is_active else "No" }}</td>
|
||||
<td>
|
||||
{% if u.id != user.id %}
|
||||
<button onclick="openEdit('{{ u.id }}', '{{ u.role }}', {{ u.is_active }})">Edit</button>
|
||||
<button class="btn-danger" onclick="deactivate('{{ u.id }}')">Deactivate</button>
|
||||
{% else %}
|
||||
<em>(you)</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Create user modal -->
|
||||
<dialog id="create-modal">
|
||||
<form id="create-form">
|
||||
<h3>New user</h3>
|
||||
<label>Username<input name="username" required></label>
|
||||
<label>Password<input type="password" name="password" required></label>
|
||||
<label>Role
|
||||
<select name="role">
|
||||
<option>reader</option>
|
||||
<option>admin</option>
|
||||
<option>superadmin</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="submit" class="btn-primary">Create</button>
|
||||
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
</div>
|
||||
<p id="create-error" class="error"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Edit user modal -->
|
||||
<dialog id="edit-modal">
|
||||
<form id="edit-form">
|
||||
<h3>Edit user</h3>
|
||||
<input type="hidden" id="edit-user-id">
|
||||
<label>New password (leave blank to keep)<input type="password" id="edit-password"></label>
|
||||
<label>Role
|
||||
<select id="edit-role">
|
||||
<option>reader</option>
|
||||
<option>admin</option>
|
||||
<option>superadmin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" id="edit-active"> Active</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
</div>
|
||||
<p id="edit-error" class="error"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
document.getElementById("create-form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const res = await fetch("/users", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({username: fd.get("username"), password: fd.get("password"), role: fd.get("role")}),
|
||||
});
|
||||
if (res.ok) { location.reload(); }
|
||||
else { document.getElementById("create-error").textContent = await res.text(); }
|
||||
});
|
||||
|
||||
function openEdit(id, role, isActive) {
|
||||
document.getElementById("edit-user-id").value = id;
|
||||
document.getElementById("edit-role").value = role;
|
||||
document.getElementById("edit-active").checked = !!isActive;
|
||||
document.getElementById("edit-password").value = "";
|
||||
document.getElementById("edit-modal").showModal();
|
||||
}
|
||||
|
||||
document.getElementById("edit-form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById("edit-user-id").value;
|
||||
const body = {
|
||||
role: document.getElementById("edit-role").value,
|
||||
is_active: document.getElementById("edit-active").checked,
|
||||
};
|
||||
const pw = document.getElementById("edit-password").value;
|
||||
if (pw) body.password = pw;
|
||||
const res = await fetch(`/users/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) { location.reload(); }
|
||||
else { document.getElementById("edit-error").textContent = await res.text(); }
|
||||
});
|
||||
|
||||
async function deactivate(id) {
|
||||
if (!confirm("Deactivate this user?")) return;
|
||||
const res = await fetch(`/users/${id}`, {method: "DELETE"});
|
||||
if (res.ok) { location.reload(); }
|
||||
else { alert(await res.text()); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user