You\'re still using the password you were handed. Setting your own here also signs out every other device.
' + : 'Changing your password signs out every other device.
'} +diff --git a/.gitignore b/.gitignore
index 51af032..3392e8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ __pycache__/
data/
.venv/
.DS_Store
+.env.bak-*
diff --git a/README.md b/README.md
index 58a49f8..05aeffb 100644
--- a/README.md
+++ b/README.md
@@ -45,26 +45,34 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
-.venv/bin/python -m tests.test_api # 122 checks, in-process
-.venv/bin/python -m tests.test_concurrency # 12 checks, against a real uvicorn
+.venv/bin/python -m tests.test_api # 166 checks, in-process
+.venv/bin/python -m tests.test_concurrency # 15 checks, against a real uvicorn
```
`test_api` exercises the API end to end against a throwaway database — the auth
gate and session-token signing, nested categories and locations, search across
every indexed field, filter rollups, stock adjustment and history, patch
semantics including explicit nulls, taxonomy cycle rejection, security headers
-and asset versioning, and login throttling.
+and asset versioning, login throttling, and the full password-management flow
+including scrypt hashing, session invalidation and the recovery CLI.
`test_concurrency` needs a real server process, because a lost update only shows
up when two requests genuinely overlap inside SQLite. It fires overlapping
adjustments, patches and creates at one part and asserts the stock log always
-sums to the stored quantity.
+sums to the stored quantity, and races taxonomy renames against reads to check
+the search index never describes a name the tree no longer has.
## Configuration
+The password is **managed from the app**, not from a config file. `PARTS_PASSWORD`
+is only the bootstrap credential: it works until a password is set through the
+UI, and is ignored from then on (otherwise "changing" the password would leave
+the old one working). The stored password is a salted scrypt hash in the
+`settings` table, which lives in the `/data` volume and survives rebuilds.
+
| Variable | Meaning |
|---|---|
-| `PARTS_PASSWORD` | The single shared password. Required when auth is on. |
+| `PARTS_PASSWORD` | Bootstrap password, used only until one is set in the app. |
| `PARTS_SECRET` | Signing key for the session cookie. `openssl rand -hex 32`. |
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
@@ -73,9 +81,11 @@ sums to the stored quantity.
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
-Rotating `PARTS_PASSWORD` alone does **not** invalidate existing sessions when
-`PARTS_SECRET` is set independently — the cookie is signed with the secret.
-Rotate both to revoke every outstanding cookie.
+Every token carries a session epoch. Changing the password bumps it, which
+invalidates every outstanding cookie at once while re-issuing one for the
+browser that made the change — so a password change really does sign out other
+devices, with no need to touch `PARTS_SECRET` on the host. **Sign out other
+devices** in Settings bumps the epoch on its own.
Failed logins are throttled on a **global** window rather than per source
address. Caddy appends to `X-Forwarded-For` instead of replacing it, so the
@@ -90,11 +100,16 @@ Lives at `/home/jay/srv/parts/` on `.8` and follows the same conventions as the
other services there — `build: .`, external `caddy_web` network, named volume
for `/data`.
+`~/srv/parts` is a checkout of this repository, so deploying is a pull:
+
```sh
ssh jay@192.168.50.8
-cd ~/srv/parts && sudo docker compose up -d --build
+cd ~/srv/parts && git pull && docker compose up -d --build
```
+`.env` is untracked and gitignored, so it survives the pull. `~/srv` (the infra
+repo) ignores `parts/`, since this directory is its own repository.
+
The database is in the `parts_parts_data` docker volume, which survives
rebuilds.
@@ -135,6 +150,22 @@ The "what could I build with what I'm holding?" idea is meant to arrive as
another consumer of these endpoints — the arbiter on `.8` already has the lane
routing and typed-action machinery for it — rather than as a fork of them.
+## Passwords
+
+Open **Settings** (the gear in the header) and use the Password section. It asks
+for the current password, takes a new one twice, and signs out every other
+device. Until a password has been set in the app, a banner says so.
+
+The only reason to touch a terminal is a forgotten password:
+
+```sh
+docker exec -it parts python -m app.admin set-password # prompts, twice
+docker exec parts python -m app.admin show-status # where the password comes from
+docker exec parts python -m app.admin clear-password # fall back to PARTS_PASSWORD
+```
+
+Each of those signs out every session too.
+
## Hardening notes
Responses carry `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`
diff --git a/app/admin.py b/app/admin.py
new file mode 100644
index 0000000..41f2ec8
--- /dev/null
+++ b/app/admin.py
@@ -0,0 +1,65 @@
+"""Recovery CLI.
+
+Everyday password changes happen in the UI. This exists for the one case the UI
+cannot help with — a forgotten password — and for the initial handover.
+
+ docker exec -it parts python -m app.admin set-password
+ docker exec parts python -m app.admin show-status
+"""
+
+import getpass
+import sys
+
+from . import auth, db
+
+
+def set_password(argv):
+ password = argv[0] if argv else None
+ if password is None:
+ password = getpass.getpass("New password: ")
+ if password != getpass.getpass("Repeat: "):
+ print("Passwords did not match.", file=sys.stderr)
+ return 1
+ problem = auth.password_problem(password)
+ if problem:
+ print(problem, file=sys.stderr)
+ return 1
+ with db.session() as conn:
+ auth.set_password(conn, password)
+ print("Password set. All existing sessions have been signed out.")
+ return 0
+
+
+def clear_password(_argv):
+ """Fall back to the PARTS_PASSWORD environment variable again."""
+ with db.session() as conn:
+ conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
+ auth.bump_epoch(conn)
+ print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
+ print("All existing sessions have been signed out.")
+ return 0
+
+
+def show_status(_argv):
+ with db.session() as conn:
+ stored = auth.stored_hash(conn)
+ print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var")
+ print("session epoch :", auth.current_epoch(conn))
+ print("auth enabled :", auth.auth_enabled())
+ return 0
+
+
+COMMANDS = {"set-password": set_password, "clear-password": clear_password, "show-status": show_status}
+
+
+def main(argv=None):
+ argv = list(sys.argv[1:] if argv is None else argv)
+ if not argv or argv[0] not in COMMANDS:
+ print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
+ return 2
+ db.init()
+ return COMMANDS[argv[0]](argv[1:])
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/app/auth.py b/app/auth.py
index 24aac2c..ea9c9a4 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -9,10 +9,13 @@ import base64
import hashlib
import hmac
import os
+import secrets
import threading
import time
-from fastapi import Cookie, HTTPException, Request
+from fastapi import Cookie, Depends, HTTPException, Request
+
+from . import db
COOKIE_NAME = "parts_session"
DIGEST_SIZE = hashlib.sha256().digest_size
@@ -46,13 +49,103 @@ def session_days() -> int:
return _env_int("PARTS_SESSION_DAYS", 30)
-def check_password(candidate: str) -> bool:
+# --- stored password --------------------------------------------------------
+#
+# The password lives in the database, not the environment, so it can be changed
+# from the UI and survives a container rebuild (it is in the /data volume).
+# PARTS_PASSWORD is only the bootstrap credential: it works until a password has
+# been set through the app, and is ignored from then on — otherwise "changing"
+# the password would leave the old one working.
+
+PASSWORD_KEY = "password_hash"
+EPOCH_KEY = "session_epoch"
+MIN_PASSWORD_LENGTH = 8
+
+_SCRYPT_N = 1 << 14
+_SCRYPT_R = 8
+_SCRYPT_P = 1
+_SCRYPT_MAXMEM = 64 * 1024 * 1024
+
+
+def hash_password(password: str) -> str:
+ salt = secrets.token_bytes(16)
+ digest = hashlib.scrypt(
+ password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
+ dklen=32, maxmem=_SCRYPT_MAXMEM,
+ )
+ return "scrypt${}${}${}${}${}".format(
+ _SCRYPT_N, _SCRYPT_R, _SCRYPT_P,
+ base64.b64encode(salt).decode(), base64.b64encode(digest).decode(),
+ )
+
+
+def verify_hash(password: str, stored: str) -> bool:
+ try:
+ scheme, n, r, pp, salt_b64, digest_b64 = stored.split("$")
+ if scheme != "scrypt":
+ return False
+ digest = hashlib.scrypt(
+ password.encode(), salt=base64.b64decode(salt_b64),
+ n=int(n), r=int(r), p=int(pp), dklen=len(base64.b64decode(digest_b64)),
+ maxmem=_SCRYPT_MAXMEM,
+ )
+ return hmac.compare_digest(digest, base64.b64decode(digest_b64))
+ except Exception:
+ return False
+
+
+def stored_hash(conn) -> str | None:
+ return db.get_setting(conn, PASSWORD_KEY)
+
+
+def using_bootstrap_password(conn) -> bool:
+ """True while the env-provided password is still the live one."""
+ return stored_hash(conn) is None
+
+
+def check_password(conn, candidate: str) -> bool:
+ stored = stored_hash(conn)
+ if stored:
+ return verify_hash(candidate, stored)
expected = _env("PARTS_PASSWORD")
if not expected:
return False
return hmac.compare_digest(candidate.encode(), expected.encode())
+def set_password(conn, new_password: str):
+ """Store a new password and invalidate every outstanding session."""
+ db.set_setting(conn, PASSWORD_KEY, hash_password(new_password))
+ bump_epoch(conn)
+
+
+def password_problem(new_password: str) -> str | None:
+ if len(new_password) < MIN_PASSWORD_LENGTH:
+ return f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
+ if not new_password.strip():
+ return "Password must not be blank"
+ return None
+
+
+# --- session epoch ----------------------------------------------------------
+#
+# Baked into every token. Bumping it invalidates all outstanding cookies at
+# once, which is what makes "change my password" and "sign out everywhere"
+# work without having to rotate PARTS_SECRET by hand on the host.
+
+def current_epoch(conn) -> int:
+ try:
+ return int(db.get_setting(conn, EPOCH_KEY, "0") or "0")
+ except ValueError:
+ return 0
+
+
+def bump_epoch(conn) -> int:
+ epoch = current_epoch(conn) + 1
+ db.set_setting(conn, EPOCH_KEY, epoch)
+ return epoch
+
+
# --- session tokens ---------------------------------------------------------
#
# The signature is raw HMAC bytes, which can contain any byte value including
@@ -61,14 +154,14 @@ def check_password(candidate: str) -> bool:
# split in the wrong place and failed their own validator. The digest is a fixed
# 32 bytes, so slice by length instead of looking for a delimiter.
-def issue_token() -> str:
+def issue_token(conn) -> str:
expires = int(time.time()) + session_days() * 86400
- payload = str(expires).encode()
+ payload = f"{expires}:{current_epoch(conn)}".encode()
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(payload + sig).decode()
-def token_valid(token: str) -> bool:
+def token_valid(token: str, conn=None) -> bool:
try:
raw = base64.urlsafe_b64decode(token.encode())
if len(raw) <= DIGEST_SIZE:
@@ -77,7 +170,12 @@ def token_valid(token: str) -> bool:
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return False
- return int(payload) > time.time()
+ expires_s, _, epoch_s = payload.decode().partition(":")
+ if int(expires_s) <= time.time():
+ return False
+ if conn is not None and int(epoch_s or 0) != current_epoch(conn):
+ return False
+ return True
except Exception:
return False
@@ -149,10 +247,15 @@ def is_https(request: Request) -> bool:
return request.url.scheme == "https"
-def require_auth(parts_session: str | None = Cookie(default=None)):
- """FastAPI dependency guarding every data route."""
+def require_auth(parts_session: str | None = Cookie(default=None), conn=Depends(db.get_db)):
+ """FastAPI dependency guarding every data route.
+
+ Takes the same request-scoped connection the route uses (FastAPI caches
+ dependency results per request), so checking the session epoch costs one
+ indexed lookup rather than a second connection.
+ """
if not auth_enabled():
return True
- if parts_session and token_valid(parts_session):
+ if parts_session and token_valid(parts_session, conn):
return True
raise HTTPException(status_code=401, detail="Not authenticated")
diff --git a/app/db.py b/app/db.py
index fc09356..81e3bb4 100644
--- a/app/db.py
+++ b/app/db.py
@@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS stock_log (
);
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
+-- Small key/value store for things that must outlive a container rebuild and
+-- be changeable without editing a file on the host: the password hash and the
+-- session epoch live here, in the /data volume.
+CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
name, description, manufacturer, mpn, specs, tags, category, location,
tokenize='unicode61 remove_diacritics 2'
@@ -131,6 +139,19 @@ def init():
_initialised = True
+def get_setting(conn, key: str, default: str | None = None) -> str | None:
+ row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
+ return row["value"] if row else default
+
+
+def set_setting(conn, key: str, value: str):
+ conn.execute(
+ "INSERT INTO settings(key, value) VALUES (?, ?) "
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ (key, str(value)),
+ )
+
+
@contextmanager
def session():
conn = connect()
diff --git a/app/main.py b/app/main.py
index cfcfc7d..c4f72d3 100644
--- a/app/main.py
+++ b/app/main.py
@@ -9,12 +9,14 @@ consumer of the same endpoints rather than a fork of them.
import asyncio
import hashlib
import json
+import math
import os
import sqlite3
from contextlib import asynccontextmanager
from typing import Annotated, Any, Literal
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
+from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import AfterValidator, BaseModel, Field
@@ -69,10 +71,10 @@ class PartIn(BaseModel):
location_id: int | None = None
manufacturer: Text = Field(default="", max_length=200)
mpn: Text = Field(default="", max_length=120)
- quantity: float = Field(default=0, ge=0)
+ quantity: float = Field(default=0, ge=0, allow_inf_nan=False)
unit: RequiredText = Field(default="pcs", max_length=20)
- min_quantity: float | None = Field(default=None, ge=0)
- cost_each: float | None = Field(default=None, ge=0)
+ min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
+ cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
datasheet_url: Text = Field(default="", max_length=1000)
product_url: Text = Field(default="", max_length=1000)
notes: Text = Field(default="", max_length=4000)
@@ -87,10 +89,10 @@ class PartPatch(BaseModel):
location_id: int | None = None
manufacturer: Text | None = Field(default=None, max_length=200)
mpn: Text | None = Field(default=None, max_length=120)
- quantity: float | None = Field(default=None, ge=0)
+ quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
unit: RequiredText | None = Field(default=None, max_length=20)
- min_quantity: float | None = Field(default=None, ge=0)
- cost_each: float | None = Field(default=None, ge=0)
+ min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
+ cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
datasheet_url: Text | None = Field(default=None, max_length=1000)
product_url: Text | None = Field(default=None, max_length=1000)
notes: Text | None = Field(default=None, max_length=4000)
@@ -109,7 +111,7 @@ PART_COLUMNS = [
class AdjustIn(BaseModel):
- delta: float
+ delta: float = Field(allow_inf_nan=False)
reason: Text = Field(default="", max_length=300)
@@ -126,6 +128,11 @@ class LoginIn(BaseModel):
password: str = Field(max_length=500)
+class PasswordChangeIn(BaseModel):
+ current_password: str = Field(max_length=500)
+ new_password: str = Field(max_length=500)
+
+
# --- helpers ----------------------------------------------------------------
def _specs_for(conn, part_ids: list[int]) -> dict[int, list[dict]]:
@@ -240,6 +247,32 @@ def _check_parent(conn, table: str, node_id: int, parent_id: int | None, label:
raise HTTPException(400, f"A {label} cannot be moved beneath itself")
+# --- error handling ---------------------------------------------------------
+
+def _json_safe(value):
+ """Make a validation-error payload encodable.
+
+ FastAPI echoes the rejected value back in the error detail. When that value
+ is Infinity or NaN — the very thing being rejected — the JSON encoder
+ refuses it and a clean 422 turns into a serialisation failure. Stringify
+ anything the encoder can't represent.
+ """
+ if isinstance(value, float):
+ return value if math.isfinite(value) else str(value)
+ if isinstance(value, dict):
+ return {str(k): _json_safe(v) for k, v in value.items()}
+ if isinstance(value, (list, tuple, set)):
+ return [_json_safe(v) for v in value]
+ if isinstance(value, (str, int, bool)) or value is None:
+ return value
+ return str(value)
+
+
+@app.exception_handler(RequestValidationError)
+def validation_error(request: Request, exc: RequestValidationError):
+ return JSONResponse({"detail": _json_safe(exc.errors())}, status_code=422)
+
+
# --- middleware -------------------------------------------------------------
@app.middleware("http")
@@ -268,19 +301,35 @@ async def security_headers(request: Request, call_next):
# --- auth routes ------------------------------------------------------------
+def _set_session_cookie(response: Response, request: Request, conn):
+ response.set_cookie(
+ auth.COOKIE_NAME,
+ auth.issue_token(conn),
+ max_age=auth.session_days() * 86400,
+ httponly=True,
+ samesite="lax",
+ secure=auth.is_https(request),
+ path="/",
+ )
+
+
@app.get("/api/me")
-def me(request: Request):
+def me(request: Request, conn=Depends(db.get_db)):
if not auth.auth_enabled():
- return {"authenticated": True, "auth_required": False}
+ return {"authenticated": True, "auth_required": False, "using_bootstrap_password": False}
token = request.cookies.get(auth.COOKIE_NAME)
+ authenticated = bool(token and auth.token_valid(token, conn))
return {
- "authenticated": bool(token and auth.token_valid(token)),
+ "authenticated": authenticated,
"auth_required": True,
+ # Surfaced so the UI can nag until the handed-over password is replaced.
+ "using_bootstrap_password": authenticated and auth.using_bootstrap_password(conn),
+ "min_password_length": auth.MIN_PASSWORD_LENGTH,
}
@app.post("/api/login")
-async def login(body: LoginIn, response: Response, request: Request):
+async def login(body: LoginIn, response: Response, request: Request, conn=Depends(db.get_db)):
if not auth.auth_enabled():
return {"authenticated": True, "auth_required": False}
@@ -290,7 +339,7 @@ async def login(body: LoginIn, response: Response, request: Request):
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
)
- if not auth.check_password(body.password):
+ if not auth.check_password(conn, body.password):
auth.record_failure()
# Async sleep, so a burst of guesses can't tie up the worker threadpool
# that real requests need.
@@ -298,16 +347,52 @@ async def login(body: LoginIn, response: Response, request: Request):
raise HTTPException(401, "Incorrect password")
auth.clear_failures()
- response.set_cookie(
- auth.COOKIE_NAME,
- auth.issue_token(),
- max_age=auth.session_days() * 86400,
- httponly=True,
- samesite="lax",
- secure=auth.is_https(request),
- path="/",
- )
- return {"authenticated": True, "auth_required": True}
+ _set_session_cookie(response, request, conn)
+ return {
+ "authenticated": True,
+ "auth_required": True,
+ "using_bootstrap_password": auth.using_bootstrap_password(conn),
+ }
+
+
+@app.post("/api/password", dependencies=[Depends(auth.require_auth)])
+async def change_password(
+ body: PasswordChangeIn, response: Response, request: Request, conn=Depends(db.get_db)
+):
+ """Change the password from inside the app.
+
+ Requires the current password even though the caller already holds a valid
+ session: a borrowed browser tab should not be enough to lock the owner out.
+ """
+ if not auth.auth_enabled():
+ raise HTTPException(400, "Authentication is disabled, so there is no password to change")
+
+ if not auth.check_password(conn, body.current_password):
+ auth.record_failure()
+ await asyncio.sleep(0.5)
+ raise HTTPException(403, "Current password is incorrect")
+
+ problem = auth.password_problem(body.new_password)
+ if problem:
+ raise HTTPException(422, problem)
+ if body.new_password == body.current_password:
+ raise HTTPException(422, "New password must be different from the current one")
+
+ auth.clear_failures()
+ # Bumps the session epoch, so every cookie issued before now stops working.
+ auth.set_password(conn, body.new_password)
+ # Re-issue for the caller, so changing the password doesn't log you out of
+ # the tab you changed it in.
+ _set_session_cookie(response, request, conn)
+ return {"changed": True, "other_sessions_signed_out": True}
+
+
+@app.post("/api/sessions/revoke", dependencies=[Depends(auth.require_auth)])
+def revoke_sessions(response: Response, request: Request, conn=Depends(db.get_db)):
+ """Sign out every other device, keeping this one signed in."""
+ auth.bump_epoch(conn)
+ _set_session_cookie(response, request, conn)
+ return {"revoked": True}
@app.post("/api/logout")
@@ -503,6 +588,8 @@ def adjust_part(part_id: int, body: AdjustIn, conn=Depends(db.get_db)):
@app.get("/api/parts/{part_id}/history", dependencies=[Depends(auth.require_auth)])
def part_history(part_id: int, conn=Depends(db.get_db), limit: int = Query(default=50, ge=1, le=500)):
+ if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None:
+ raise HTTPException(404, "Part not found")
rows = conn.execute(
"SELECT * FROM stock_log WHERE part_id = ? ORDER BY id DESC LIMIT ?", (part_id, limit)
).fetchall()
@@ -578,6 +665,10 @@ def create_category(body: NodeIn, conn=Depends(db.get_db)):
@app.patch("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
+ # Before the first read: the affected-parts scan and the reindex that
+ # follows it have to see one consistent tree, or a concurrent write can slip
+ # between them and leave the search index describing the old taxonomy.
+ db.begin_immediate(conn)
_require_node(conn, "categories", cat_id, "Category")
_check_parent(conn, "categories", cat_id, body.parent_id, "category")
affected = db.parts_under(conn, "categories", cat_id)
@@ -596,6 +687,10 @@ def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
@app.delete("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
def delete_category(cat_id: int, conn=Depends(db.get_db)):
+ # Before the first read: the affected-parts scan and the reindex that
+ # follows it have to see one consistent tree, or a concurrent write can slip
+ # between them and leave the search index describing the old taxonomy.
+ db.begin_immediate(conn)
_require_node(conn, "categories", cat_id, "Category")
# Capture the parts first: ON DELETE SET NULL means they survive as
# uncategorised rather than vanish, but their indexed path must be rebuilt.
@@ -626,6 +721,10 @@ def create_location(body: NodeIn, conn=Depends(db.get_db)):
@app.patch("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
+ # Before the first read: the affected-parts scan and the reindex that
+ # follows it have to see one consistent tree, or a concurrent write can slip
+ # between them and leave the search index describing the old taxonomy.
+ db.begin_immediate(conn)
_require_node(conn, "locations", loc_id, "Location")
_check_parent(conn, "locations", loc_id, body.parent_id, "location")
affected = db.parts_under(conn, "locations", loc_id)
@@ -642,6 +741,10 @@ def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
@app.delete("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
def delete_location(loc_id: int, conn=Depends(db.get_db)):
+ # Before the first read: the affected-parts scan and the reindex that
+ # follows it have to see one consistent tree, or a concurrent write can slip
+ # between them and leave the search index describing the old taxonomy.
+ db.begin_immediate(conn)
_require_node(conn, "locations", loc_id, "Location")
affected = db.parts_under(conn, "locations", loc_id)
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
diff --git a/static/app.css b/static/app.css
index 3c4483a..3b26fd2 100644
--- a/static/app.css
+++ b/static/app.css
@@ -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 {
diff --git a/static/app.js b/static/app.js
index 6baee12..c607109 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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(`
You\'re still using the password you were handed. Setting your own here also signs out every other device.
' + : 'Changing your password signs out every other device.
'} +