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:
Erik 2026-04-10 19:45:08 +02:00
parent fac5063878
commit b09169ade2
9 changed files with 878 additions and 60 deletions

268
static/admin.html Normal file
View 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">&larr; 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>

View file

@ -113,6 +113,13 @@
<ul id="playerList"></ul>
<!-- User info section (populated by script.js after /me fetch) -->
<div id="userInfo" class="user-info" style="display:none;">
<span id="currentUsername" class="user-info-name"></span>
<a href="#" id="adminLink" class="user-info-admin" style="display:none;" onclick="window.open('/admin/users','_blank')">Admin</a>
<a href="/logout" class="user-info-logout">Logout</a>
</div>
</aside>
<!-- Epic rare notifications container -->

169
static/login.html Normal file
View file

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dereth Tracker - Login</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0a;
background-image:
radial-gradient(ellipse at 50% 30%, rgba(30, 20, 10, 0.8) 0%, transparent 70%),
linear-gradient(180deg, #0a0806 0%, #12100a 50%, #0a0806 100%);
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
color: #d4c9a8;
}
.login-card {
width: 360px;
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
border: 2px solid #8a7a44;
border-radius: 6px;
padding: 32px 28px;
box-shadow:
inset 0 1px 0 rgba(212, 175, 55, 0.1),
0 8px 32px rgba(0, 0, 0, 0.8),
0 0 60px rgba(138, 122, 68, 0.08);
}
.login-title {
text-align: center;
margin-bottom: 6px;
font-size: 1.5rem;
color: #d4af37;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
letter-spacing: 1px;
}
.login-subtitle {
text-align: center;
font-size: 0.8rem;
color: #8a7a5a;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 0.8rem;
color: #a09070;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-group input {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
font-family: inherit;
background: #0e0c08;
color: #d4c9a8;
border: 1px solid #5a4a24;
border-radius: 3px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #d4af37;
box-shadow: 0 0 6px rgba(212, 175, 55, 0.15);
}
.login-btn {
width: 100%;
padding: 10px;
margin-top: 8px;
font-family: inherit;
font-size: 1rem;
font-weight: bold;
color: #1a1610;
background: linear-gradient(180deg, #d4af37 0%, #a08520 100%);
border: 1px solid #8a7a44;
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 2px;
transition: background 0.2s, box-shadow 0.2s;
}
.login-btn:hover {
background: linear-gradient(180deg, #e0c050 0%, #b89a30 100%);
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.3);
}
.login-btn:active {
background: linear-gradient(180deg, #a08520 0%, #8a7a44 100%);
}
.login-error {
margin-top: 12px;
padding: 8px;
text-align: center;
font-size: 0.8rem;
color: #ff6b6b;
background: rgba(255, 50, 50, 0.08);
border: 1px solid rgba(255, 50, 50, 0.2);
border-radius: 3px;
display: none;
}
.login-footer {
margin-top: 20px;
text-align: center;
font-size: 0.65rem;
color: #5a4a34;
}
</style>
</head>
<body>
<div class="login-card">
<h1 class="login-title">Dereth Tracker</h1>
<p class="login-subtitle">Mosswart Enjoyers Club</p>
<form id="loginForm" onsubmit="return handleLogin(event)">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="login-btn" id="loginBtn">Enter Dereth</button>
<div class="login-error" id="loginError"></div>
</form>
<div class="login-footer">Authorized personnel only</div>
</div>
<script>
async function handleLogin(e) {
e.preventDefault();
const btn = document.getElementById('loginBtn');
const errDiv = document.getElementById('loginError');
errDiv.style.display = 'none';
btn.textContent = 'Authenticating...';
btn.disabled = true;
try {
const resp = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (resp.ok) {
window.location.href = '/';
return;
}
const data = await resp.json();
errDiv.textContent = data.detail || 'Login failed';
errDiv.style.display = 'block';
} catch (err) {
errDiv.textContent = 'Connection error';
errDiv.style.display = 'block';
}
btn.textContent = 'Enter Dereth';
btn.disabled = false;
}
</script>
</body>
</html>

View file

@ -4125,6 +4125,26 @@ fetch('/api-version').then(r => r.json()).then(d => {
if (el) el.textContent = 'v' + d.version;
}).catch(() => {});
// ─── Current User Info ──────────────────────────────────────────
let _currentUser = null;
fetch('/me').then(r => {
if (!r.ok) throw new Error('not authenticated');
return r.json();
}).then(data => {
_currentUser = data;
const userInfo = document.getElementById('userInfo');
const nameEl = document.getElementById('currentUsername');
const adminLink = document.getElementById('adminLink');
if (userInfo && nameEl) {
nameEl.textContent = data.username;
userInfo.style.display = 'flex';
}
if (adminLink && data.is_admin) {
adminLink.style.display = 'inline';
}
}).catch(() => {});
// ─── Issues Board ───────────────────────────────────────────────
const ISSUE_CATEGORIES = {
@ -4165,7 +4185,6 @@ function showIssuesWindow() {
form.className = 'issues-form';
form.innerHTML = `
<div style="display:flex;gap:4px;margin-bottom:4px;">
<input type="text" id="issueAuthor" placeholder="Your name..." style="width:120px;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<input type="text" id="issueTitle" placeholder="Issue title..." style="flex:1;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<select id="issueCategory" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<option value="plugin">Plugin</option>
@ -4182,24 +4201,17 @@ function showIssuesWindow() {
`;
content.appendChild(form);
// Remember author name in localStorage
const authorInput = form.querySelector('#issueAuthor');
authorInput.value = localStorage.getItem('issueAuthorName') || '';
// Add button handler
form.querySelector('#issueAddBtn').addEventListener('click', async () => {
const author = document.getElementById('issueAuthor').value.trim() || 'Anonymous';
const title = document.getElementById('issueTitle').value.trim();
const desc = document.getElementById('issueDescription').value.trim();
const cat = document.getElementById('issueCategory').value;
if (!title) return;
localStorage.setItem('issueAuthorName', author);
await fetch('/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc, category: cat, author })
body: JSON.stringify({ title, description: desc, category: cat })
});
document.getElementById('issueTitle').value = '';
document.getElementById('issueDescription').value = '';
@ -4406,11 +4418,10 @@ function showCommentsSection(row, issue, win) {
addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => {
const text = addDiv.querySelector('.comment-text-input').value.trim();
if (!text) return;
const author = localStorage.getItem('issueAuthorName') || 'Anonymous';
await fetch(`/issues/${issue.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, author })
body: JSON.stringify({ text })
});
refreshIssuesList(win);
});

View file

@ -117,6 +117,8 @@ body {
box-sizing: border-box;
padding: 18px 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
#sidebar h2 {
margin: 8px 0 12px;
@ -2832,3 +2834,41 @@ table.ts-allegiance td:first-child {
.issue-comment-form {
margin-top: 4px;
}
/* ---------- User info section (sidebar bottom) ---------- */
.user-info {
margin-top: auto;
padding: 10px 0 0;
border-top: 1px solid #333;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
flex-shrink: 0;
}
.user-info-name {
color: #d4af37;
font-weight: bold;
}
.user-info-admin {
color: #8a7a44;
text-decoration: none;
font-size: 0.7rem;
}
.user-info-admin:hover {
color: #d4af37;
}
.user-info-logout {
margin-left: auto;
color: #888;
text-decoration: none;
font-size: 0.7rem;
}
.user-info-logout:hover {
color: #ff6b6b;
}