Add secure username/password login portal

Gate the suite behind a self-contained login (no external IdP):

- User model with bcrypt-hashed passwords; admin/user roles
- /api/auth endpoints: login, logout, me, change-password, and
  admin-only user management (list/create/delete/reset/enable)
- Stateless JWT session in an HttpOnly, SameSite=Lax, auto-Secure
  cookie; middleware refuses every /api data route without a session
- login.html + auth-guard.js: login page and per-page guard with a
  top-right "name / Admin / Sign out" pill
- Admin Console now gated on admin role (passphrase gate removed) with
  a User administration card
- manage_users.py CLI to bootstrap the first admin
- Rebuilt help.js into a searchable, multi-topic help center
- Local-dev convenience: app serves html/ so the site + API share one
  origin under uvicorn (inactive in the prod container)
- Docs/env: AUTH_SECRET_KEY, requirements (bcrypt, PyJWT), README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:55:54 -05:00
parent a02f7ec511
commit 20afb0e565
18 changed files with 1470 additions and 99 deletions

59
html/login.js Normal file
View File

@@ -0,0 +1,59 @@
/* Login page logic for the Work Package Suite.
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
session cookie (not readable here — that's the point) and we redirect to the
page the user was trying to reach, or the home page. */
(function () {
'use strict';
var form = document.getElementById('login-form');
var errorBox = document.getElementById('error');
var submitBtn = document.getElementById('submit');
// Where to go after signing in: the ?next= param if it's a safe same-site
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
// open-redirect.)
function nextTarget() {
try {
var next = new URLSearchParams(location.search).get('next') || '';
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
} catch (e) {}
return 'index.html';
}
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.add('show');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
errorBox.classList.remove('show');
var username = document.getElementById('username').value.trim();
var password = document.getElementById('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…';
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, password: password })
})
.then(function (r) {
if (r.ok) { location.replace(nextTarget()); return null; }
return r.json().catch(function () { return null; }).then(function (j) {
if (r.status === 401) showError('Invalid username or password.');
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
});
})();