Files
terra-view/templates/admin/users.html
T
serversdown bff9a4af4a feat(auth): superadmin user-management page + CRUD
/admin/users page and /api/admin/users/* JSON CRUD endpoints, all behind
require_role("superadmin"). Temp passwords are returned once on create/reset
and never stored in plaintext. Admins get 403; password_hash is never leaked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:48:13 +00:00

72 lines
3.3 KiB
HTML

{% extends "base.html" %}
{% block title %}Operator Accounts{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto p-4">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Operator Accounts</h1>
<button id="add-user-btn" class="px-3 py-2 rounded bg-orange-500 hover:bg-orange-600 text-white text-sm">+ Add operator</button>
</div>
<div id="temp-pw-banner" class="hidden mb-4 px-3 py-2 rounded bg-emerald-900/60 text-emerald-100 text-sm"></div>
<table class="w-full text-sm">
<thead><tr class="text-left border-b border-slate-600">
<th class="py-2">Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th></th>
</tr></thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<script>
const $ = (s) => document.querySelector(s);
function showTemp(email, pw) {
const b = $("#temp-pw-banner");
b.textContent = `Temporary password for ${email}: ${pw} — copy it now, it won't be shown again.`;
b.classList.remove("hidden");
}
async function load() {
const r = await fetch("/api/admin/users");
const { users } = await r.json();
$("#user-rows").innerHTML = users.map(u => `
<tr class="border-b border-slate-700">
<td class="py-2">${u.display_name}</td>
<td>${u.email}</td>
<td>
<select data-role="${u.id}" class="bg-slate-700 rounded px-1 py-0.5">
<option value="admin"${u.role==='admin'?' selected':''}>admin</option>
<option value="superadmin"${u.role==='superadmin'?' selected':''}>superadmin</option>
</select>
</td>
<td>${u.active ? 'active' : 'disabled'}${u.locked ? ' (locked)' : ''}</td>
<td>${u.last_login_at || '—'}</td>
<td class="text-right space-x-2">
<button data-reset="${u.id}" data-email="${u.email}" class="text-orange-400 hover:underline">Reset pw</button>
<button data-toggle="${u.id}" data-active="${u.active}" class="text-slate-300 hover:underline">${u.active ? 'Disable' : 'Enable'}</button>
</td>
</tr>`).join("");
}
document.addEventListener("click", async (e) => {
if (e.target.dataset.reset) {
const r = await fetch(`/api/admin/users/${e.target.dataset.reset}/reset-password`, {method:"POST"});
const d = await r.json(); showTemp(e.target.dataset.email, d.password); load();
} else if (e.target.dataset.toggle) {
const action = e.target.dataset.active === "true" ? "disable" : "enable";
await fetch(`/api/admin/users/${e.target.dataset.toggle}/${action}`, {method:"POST"}); load();
} else if (e.target.id === "add-user-btn") {
const email = prompt("Email?"); if (!email) return;
const name = prompt("Display name?") || email;
const role = prompt("Role (admin / superadmin)?", "admin") || "admin";
const r = await fetch("/api/admin/users", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({email, name, role})});
if (r.ok) { const d = await r.json(); showTemp(email, d.password); load(); }
else { alert((await r.json()).detail || "Failed"); }
}
});
document.addEventListener("change", async (e) => {
if (e.target.dataset.role) {
await fetch(`/api/admin/users/${e.target.dataset.role}/role`, {method:"POST",
headers:{"Content-Type":"application/json"}, body: JSON.stringify({role: e.target.value})});
load();
}
});
load();
</script>
{% endblock %}