feat: add app-level authentication with login, session cookies, and admin panel
Replace Nginx basic auth with proper user accounts: - Session cookies via itsdangerous (30-day expiry, httponly, secure) - Password hashing with bcrypt via passlib - Login page with AC-themed UI - Admin page for user management (CRUD) - AuthMiddleware exempts plugin WS and browser WS endpoints - Issues/comments author auto-populated from session - Sidebar shows logged-in username, admin link, and logout - Seed users: erik (admin), alex, lundberg - SECRET_KEY env var for cookie signing
This commit is contained in:
parent
fac5063878
commit
b09169ade2
9 changed files with 878 additions and 60 deletions
268
static/admin.html
Normal file
268
static/admin.html
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dereth Tracker - Admin</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
|
||||
color: #d4c9a8;
|
||||
padding: 40px;
|
||||
}
|
||||
.admin-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
|
||||
border: 2px solid #8a7a44;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.8);
|
||||
}
|
||||
h1 {
|
||||
color: #d4af37;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
color: #8a7a44;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.back-link:hover { color: #d4af37; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #2a2418;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
th {
|
||||
color: #a09070;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
td { color: #d4c9a8; }
|
||||
.badge-admin {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: #d4af37;
|
||||
color: #1a1610;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.badge-user {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: #3a3a3a;
|
||||
color: #aaa;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.btn-danger { color: #c44; border-color: #c44; }
|
||||
.btn-danger:hover { background: #c44; color: #fff; }
|
||||
.btn-warn { color: #ca4; border-color: #ca4; }
|
||||
.btn-warn:hover { background: #ca4; color: #fff; }
|
||||
.add-form {
|
||||
padding: 16px;
|
||||
background: #151210;
|
||||
border: 1px solid #3a2818;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.add-form h3 {
|
||||
color: #a09070;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.form-row input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: #0e0c08;
|
||||
color: #d4c9a8;
|
||||
border: 1px solid #5a4a24;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
.form-row input:focus { border-color: #d4af37; }
|
||||
.form-row label {
|
||||
font-size: 0.75rem;
|
||||
color: #a09070;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-row input[type="checkbox"] {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.btn-add {
|
||||
padding: 6px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
color: #1a1610;
|
||||
background: linear-gradient(180deg, #d4af37 0%, #a08520 100%);
|
||||
border: 1px solid #8a7a44;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-add:hover { background: linear-gradient(180deg, #e0c050 0%, #b89a30 100%); }
|
||||
.msg {
|
||||
margin-top: 8px;
|
||||
font-size: 0.75rem;
|
||||
padding: 6px;
|
||||
border-radius: 3px;
|
||||
display: none;
|
||||
}
|
||||
.msg-error { color: #ff6b6b; background: rgba(255,50,50,0.08); border: 1px solid rgba(255,50,50,0.2); }
|
||||
.msg-ok { color: #4a4; background: rgba(74,170,74,0.08); border: 1px solid rgba(74,170,74,0.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<a href="/" class="back-link">← Back to Tracker</a>
|
||||
<h1>User Management</h1>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody id="userTableBody"><tr><td colspan="4">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<div class="add-form">
|
||||
<h3>Add New User</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="newUsername" placeholder="Username">
|
||||
<input type="password" id="newPassword" placeholder="Password">
|
||||
<label><input type="checkbox" id="newIsAdmin"> Admin</label>
|
||||
<button class="btn-add" onclick="addUser()">Add</button>
|
||||
</div>
|
||||
<div class="msg msg-error" id="addError"></div>
|
||||
<div class="msg msg-ok" id="addOk"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadUsers() {
|
||||
const tbody = document.getElementById('userTableBody');
|
||||
try {
|
||||
const resp = await fetch('/api-admin/users');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
const data = await resp.json();
|
||||
tbody.innerHTML = '';
|
||||
data.users.forEach(u => {
|
||||
const date = u.created_at ? new Date(u.created_at).toLocaleDateString('sv-SE') : '';
|
||||
const role = u.is_admin
|
||||
? '<span class="badge-admin">ADMIN</span>'
|
||||
: '<span class="badge-user">USER</span>';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${esc(u.username)}</td>
|
||||
<td>${role}</td>
|
||||
<td>${date}</td>
|
||||
<td>
|
||||
<button class="btn btn-warn" onclick="resetPw(${u.id}, '${esc(u.username)}')">Reset PW</button>
|
||||
<button class="btn btn-danger" onclick="delUser(${u.id}, '${esc(u.username)}')">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="color:#c44">Failed to load users</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
const errDiv = document.getElementById('addError');
|
||||
const okDiv = document.getElementById('addOk');
|
||||
errDiv.style.display = 'none';
|
||||
okDiv.style.display = 'none';
|
||||
const username = document.getElementById('newUsername').value.trim();
|
||||
const password = document.getElementById('newPassword').value;
|
||||
const is_admin = document.getElementById('newIsAdmin').checked;
|
||||
if (!username || !password) { showMsg(errDiv, 'Username and password required'); return; }
|
||||
try {
|
||||
const resp = await fetch('/api-admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, is_admin }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const d = await resp.json();
|
||||
showMsg(errDiv, d.detail || 'Failed');
|
||||
return;
|
||||
}
|
||||
document.getElementById('newUsername').value = '';
|
||||
document.getElementById('newPassword').value = '';
|
||||
document.getElementById('newIsAdmin').checked = false;
|
||||
showMsg(okDiv, `User "${username}" created`);
|
||||
loadUsers();
|
||||
} catch (e) { showMsg(errDiv, 'Connection error'); }
|
||||
}
|
||||
|
||||
async function delUser(id, name) {
|
||||
if (!confirm(`Delete user "${name}"?`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api-admin/users/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) { const d = await resp.json(); alert(d.detail || 'Failed'); return; }
|
||||
loadUsers();
|
||||
} catch (e) { alert('Connection error'); }
|
||||
}
|
||||
|
||||
async function resetPw(id, name) {
|
||||
const pw = prompt(`New password for "${name}":`);
|
||||
if (!pw) return;
|
||||
try {
|
||||
const resp = await fetch(`/api-admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
});
|
||||
if (!resp.ok) { const d = await resp.json(); alert(d.detail || 'Failed'); return; }
|
||||
alert('Password updated');
|
||||
} catch (e) { alert('Connection error'); }
|
||||
}
|
||||
|
||||
function showMsg(el, text) {
|
||||
el.textContent = text;
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue