Manage the password in the app; close remaining audit findings
Password management moves out of the CLI entirely. The credential is now a salted scrypt hash in the database (so it survives rebuilds, living in the /data volume) rather than an environment variable; PARTS_PASSWORD is demoted to a bootstrap value that stops working the moment a password is set in the UI. Every token carries a session epoch, so changing the password — or "sign out other devices" — invalidates outstanding cookies while keeping the browser that made the change signed in. A banner nags until the handed-over password is replaced. app/admin.py remains for the one case the UI cannot cover, a forgotten password. Audit findings: - Taxonomy update and delete scanned affected parts before taking the write lock, so a concurrent rename could leave the search index matching a name the UI no longer showed. All four routes now lock first; removing the lock again makes the new test fail exactly that way. - History of a missing part returned 200 with an empty list; now 404. - Infinity and NaN passed ge=0 and failed at the database. They are rejected as 422 now, and the validation error handler no longer chokes trying to echo a non-finite value back. Checks go from 134 to 181. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,21 @@ header .brand { font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
|
||||
header .search { flex: 1 1 240px; min-width: 160px; }
|
||||
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 16px -4px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--warn);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(240, 168, 72, .10);
|
||||
color: var(--warn);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
aside {
|
||||
|
||||
+78
-4
@@ -23,6 +23,8 @@ const state = {
|
||||
locations: [],
|
||||
tags: [],
|
||||
authRequired: true,
|
||||
usingBootstrap: false,
|
||||
minPasswordLength: 8,
|
||||
};
|
||||
|
||||
// --- plumbing ---------------------------------------------------------------
|
||||
@@ -70,6 +72,7 @@ function showApp() {
|
||||
$("#login").classList.add("hidden");
|
||||
$("#app").classList.remove("hidden");
|
||||
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
|
||||
$("#bootstrap-banner").classList.toggle("hidden", !state.usingBootstrap);
|
||||
}
|
||||
|
||||
$("#login-form").addEventListener("submit", async (e) => {
|
||||
@@ -77,8 +80,9 @@ $("#login-form").addEventListener("submit", async (e) => {
|
||||
const err = $("#login-error");
|
||||
err.classList.add("hidden");
|
||||
try {
|
||||
await api("/api/login", { method: "POST", body: { password: $("#password").value } });
|
||||
const session = await api("/api/login", { method: "POST", body: { password: $("#password").value } });
|
||||
$("#password").value = "";
|
||||
state.usingBootstrap = !!session.using_bootstrap_password;
|
||||
showApp();
|
||||
await boot();
|
||||
} catch (ex) {
|
||||
@@ -493,13 +497,36 @@ function openPartModal(part) {
|
||||
|
||||
// --- manage categories / locations ------------------------------------------
|
||||
|
||||
function openManageModal() {
|
||||
function openManageModal(focusPassword = false) {
|
||||
const overlay = openModal(`
|
||||
<header>
|
||||
<strong>Categories & locations</strong>
|
||||
<strong>Settings</strong>
|
||||
<button class="ghost small" data-close>✕</button>
|
||||
</header>
|
||||
<div class="content">
|
||||
${state.authRequired ? `
|
||||
<section id="password-section" style="margin-bottom:24px">
|
||||
<label>Password</label>
|
||||
${state.usingBootstrap
|
||||
? '<p style="color:var(--warn);font-size:13px;margin:0 0 10px">You\'re still using the password you were handed. Setting your own here also signs out every other device.</p>'
|
||||
: '<p style="color:var(--dim);font-size:13px;margin:0 0 10px">Changing your password signs out every other device.</p>'}
|
||||
<div class="grid">
|
||||
<div class="full">
|
||||
<input id="pw-current" type="password" autocomplete="current-password" placeholder="Current password">
|
||||
</div>
|
||||
<div>
|
||||
<input id="pw-new" type="password" autocomplete="new-password" placeholder="New password">
|
||||
</div>
|
||||
<div>
|
||||
<input id="pw-confirm" type="password" autocomplete="new-password" placeholder="Repeat new password">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
|
||||
<button class="small primary" id="pw-save">Change password</button>
|
||||
<button class="small ghost" id="pw-revoke" title="Keeps this device signed in">Sign out other devices</button>
|
||||
<span id="pw-msg" style="font-size:13px"></span>
|
||||
</div>
|
||||
</section>` : ""}
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>New category</label>
|
||||
@@ -530,6 +557,50 @@ function openManageModal() {
|
||||
|
||||
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
|
||||
|
||||
const pwSave = overlay.querySelector("#pw-save");
|
||||
if (pwSave) {
|
||||
const msg = overlay.querySelector("#pw-msg");
|
||||
const say = (text, bad) => {
|
||||
msg.textContent = text;
|
||||
msg.style.color = bad ? "var(--bad)" : "var(--good)";
|
||||
};
|
||||
pwSave.onclick = async () => {
|
||||
const current = overlay.querySelector("#pw-current").value;
|
||||
const next = overlay.querySelector("#pw-new").value;
|
||||
const confirmed = overlay.querySelector("#pw-confirm").value;
|
||||
if (!current) return say("Enter your current password", true);
|
||||
if (next !== confirmed) return say("New passwords don't match", true);
|
||||
if (next.length < state.minPasswordLength)
|
||||
return say(`At least ${state.minPasswordLength} characters`, true);
|
||||
pwSave.disabled = true;
|
||||
try {
|
||||
await api("/api/password", {
|
||||
method: "POST",
|
||||
body: { current_password: current, new_password: next },
|
||||
});
|
||||
overlay.querySelectorAll("#pw-current, #pw-new, #pw-confirm").forEach((i) => (i.value = ""));
|
||||
state.usingBootstrap = false;
|
||||
$("#bootstrap-banner").classList.add("hidden");
|
||||
say("Password changed. Other devices signed out.", false);
|
||||
toast("Password changed");
|
||||
} catch (ex) {
|
||||
say(ex.message, true);
|
||||
} finally {
|
||||
pwSave.disabled = false;
|
||||
}
|
||||
};
|
||||
overlay.querySelector("#pw-revoke").onclick = async () => {
|
||||
if (!confirm("Sign out every other device? This one stays signed in.")) return;
|
||||
try {
|
||||
await api("/api/sessions/revoke", { method: "POST" });
|
||||
say("Other devices signed out.", false);
|
||||
} catch (ex) {
|
||||
say(ex.message, true);
|
||||
}
|
||||
};
|
||||
if (focusPassword) setTimeout(() => overlay.querySelector("#pw-current").focus(), 50);
|
||||
}
|
||||
|
||||
const renderRows = () => {
|
||||
const draw = (items, box, kind) => {
|
||||
box.innerHTML = "";
|
||||
@@ -645,7 +716,8 @@ $("#sort").addEventListener("change", (e) => {
|
||||
|
||||
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
|
||||
$("#add-btn").onclick = () => openPartModal(null);
|
||||
$("#manage-btn").onclick = openManageModal;
|
||||
$("#manage-btn").onclick = () => openManageModal(false);
|
||||
$("#banner-change").onclick = () => openManageModal(true);
|
||||
$("#more-btn").onclick = () => search(true);
|
||||
|
||||
$("#filter-all").onclick = () => {
|
||||
@@ -669,6 +741,8 @@ async function boot() {
|
||||
try {
|
||||
const me = await api("/api/me");
|
||||
state.authRequired = me.auth_required;
|
||||
state.usingBootstrap = !!me.using_bootstrap_password;
|
||||
state.minPasswordLength = me.min_password_length || state.minPasswordLength;
|
||||
if (!me.authenticated) return showLogin();
|
||||
showApp();
|
||||
await boot();
|
||||
|
||||
+6
-1
@@ -27,10 +27,15 @@
|
||||
<span class="stat" id="stats"></span>
|
||||
<button class="ghost small mobile-only" id="filters-btn">Filters</button>
|
||||
<button class="primary" id="add-btn">+ Add</button>
|
||||
<button class="ghost small" id="manage-btn" title="Categories & locations">⚙</button>
|
||||
<button class="ghost small" id="manage-btn" title="Settings">⚙</button>
|
||||
<button class="ghost small hidden" id="logout-btn" title="Log out">⏻</button>
|
||||
</header>
|
||||
|
||||
<div id="bootstrap-banner" class="banner hidden">
|
||||
<span>You're still using the password that was handed to you. Set your own.</span>
|
||||
<button class="small primary" id="banner-change">Change password</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<aside id="sidebar">
|
||||
<h3>Filters</h3>
|
||||
|
||||
Reference in New Issue
Block a user