- 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
130 lines
4.0 KiB
HTML
130 lines
4.0 KiB
HTML
{% 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 %}
|