Add user management with admin and viewer roles

- data/users.json store; existing single account migrates to first admin
- Roles: admin (settings, import, users) and viewer (read-only, own password)
- Live role/disabled lookup; per-user session epoch so password changes,
  resets and disabling sign that user out everywhere
- Login brute-force lockout (5 fails -> 5 min), constant-time unknown-user check
- /api/users CRUD with last-admin and self-lockout safeguards
- Account page (own password + admin user management); role-aware header
- Min password length 8; recovery token resets the primary admin and clears lockouts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:34:13 -04:00
1 parent b3f1061f5c
commit 1566ac2897
7 files changed
+442 -93

No files matched your search

+6 -32
View File
@@ -88,6 +88,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
<div><h1>Settings</h1></div>
<div class="header-right">
<a href="/dashboard" class="nav-link">← Dashboard</a>
<a href="/dashboard/account" class="nav-link">Account</a>
<a href="/dashboard/logout" class="nav-link">Sign out</a>
</div>
</div>
@@ -204,17 +205,12 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
</div>
</div>
<!-- Change Password -->
<!-- Account -->
<div class="section">
<div class="section-hdr">Change Password</div>
<div class="settings-grid">
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
</div>
<div class="settings-actions">
<button class="primary" id="changePwBtn">Change Password</button>
<span id="pwMsg" class="save-msg"></span>
</div>
<div class="section-hdr">Account &amp; Users</div>
<p class="hint" style="margin:0 0 0.85rem">Change your password and manage who can sign in (admin and viewer accounts).</p>
<a class="export-link" href="/dashboard/account">Open account &amp; users →</a>
</div>
</div>
</div>
@@ -413,28 +409,6 @@ async function pollBackfill() {
document.getElementById('backfillBtn').addEventListener('click', startBackfill);
// ── Change Password ───────────────────────────────────────────────────────────
document.getElementById('changePwBtn').addEventListener('click', async () => {
const cur = document.getElementById('pwCurrent')?.value;
const nw = document.getElementById('pwNew')?.value;
const msg = document.getElementById('pwMsg');
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: cur, newPassword: nw })
});
if (r.ok) {
msg.textContent = 'Password changed'; msg.className = 'save-msg';
document.getElementById('pwCurrent').value = '';
document.getElementById('pwNew').value = '';
} else {
const e = await r.json();
msg.textContent = e.error || 'Error'; msg.className = 'save-msg err';
}
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Scroll shadow ─────────────────────────────────────────────────────────────
window.addEventListener('scroll', () => {