From ab82b5e9a991619ad28818d4dd1976cd196585d1 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 24 Aug 2026 10:31:06 -0400 Subject: [PATCH] Manage the password in the app; close remaining audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + README.md | 49 ++++++++++--- app/admin.py | 65 +++++++++++++++++ app/auth.py | 121 ++++++++++++++++++++++++++++--- app/db.py | 21 ++++++ app/main.py | 147 ++++++++++++++++++++++++++++++++------ static/app.css | 15 ++++ static/app.js | 82 +++++++++++++++++++-- static/index.html | 7 +- tests/test_api.py | 124 ++++++++++++++++++++++++++++++-- tests/test_concurrency.py | 43 +++++++++++ 11 files changed, 626 insertions(+), 49 deletions(-) create mode 100644 app/admin.py 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(`
- Categories & locations + Settings
+ ${state.authRequired ? ` +
+ + ${state.usingBootstrap + ? '

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.

'} +
+
+ +
+
+ +
+
+ +
+
+
+ + + +
+
` : ""}
@@ -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(); diff --git a/static/index.html b/static/index.html index 3b92681..3726287 100644 --- a/static/index.html +++ b/static/index.html @@ -27,10 +27,15 @@ - + + +