diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..f6029cb --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,67 @@ +import base64 +import hashlib +import hmac +import json +import os +import secrets +from pathlib import Path +from typing import Optional + +USERS_FILE = Path(os.getenv("DOCUMENT_PROCESSOR_USERS_FILE", "data/users.json")) + + +def _b64e(b: bytes) -> str: + return base64.b64encode(b).decode("utf-8") + + +def _b64d(s: str) -> bytes: + return base64.b64decode(s.encode("utf-8")) + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000) + return f"pbkdf2_sha256$200000${_b64e(salt)}${_b64e(digest)}" + + +def verify_password(password: str, stored: str) -> bool: + try: + scheme, iterations, salt_b64, digest_b64 = stored.split("$", 3) + if scheme != "pbkdf2_sha256": + return False + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + _b64d(salt_b64), + int(iterations), + ) + return hmac.compare_digest(digest, _b64d(digest_b64)) + except Exception: + return False + + +def load_users() -> list[dict]: + if not USERS_FILE.exists(): + return [] + return json.loads(USERS_FILE.read_text()) + + +def get_user_by_username(username: str) -> Optional[dict]: + username = username.strip().lower() + for user in load_users(): + if user.get("username", "").lower() == username: + return user + return None + + +def authenticate_user(username: str, password: str) -> Optional[dict]: + user = get_user_by_username(username) + if not user: + return None + if not verify_password(password, user.get("password_hash", "")): + return None + return { + "username": user["username"], + "display_name": user.get("display_name") or user["username"], + "is_admin": bool(user.get("is_admin", False)), + } diff --git a/app/main.py b/app/main.py index 6a3f86a..2b60490 100644 --- a/app/main.py +++ b/app/main.py @@ -189,7 +189,29 @@ def root_dashboard(request: Request): "avg_cocktail_rating": avg_cocktail_rating, "recent_documents": recent_documents, "recent_line_items": recent_line_item_rows, + "current_user": getattr(request.state, "current_user", None), }, ) finally: db.close() + +from starlette.middleware.sessions import SessionMiddleware +from app.routes.auth import router as auth_router + + +app.add_middleware( + SessionMiddleware, + secret_key="document-processor-change-this-secret", + session_cookie="document_processor_session", + same_site="lax", + https_only=False, +) + + +@app.middleware("http") +async def load_current_user(request, call_next): + session = request.scope.get("session", {}) or {} + request.state.current_user = session.get("current_user") + return await call_next(request) + +app.include_router(auth_router) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..7cb86c2 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.core.auth import authenticate_user + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/login") +def login_page(request: Request, error: str | None = None): + if getattr(request.state, "current_user", None): + return RedirectResponse(url="/", status_code=303) + return templates.TemplateResponse( + request=request, + name="auth/login.html", + context={"request": request, "error": error}, + ) + + +@router.post("/login") +def login_submit(request: Request, username: str = Form(...), password: str = Form(...)): + user = authenticate_user(username, password) + if not user: + return templates.TemplateResponse( + request=request, + name="auth/login.html", + context={"request": request, "error": "Invalid username or password."}, + status_code=400, + ) + request.session["current_user"] = user + return RedirectResponse(url="/", status_code=303) + + +@router.post("/logout") +def logout_submit(request: Request): + request.session.pop("current_user", None) + return RedirectResponse(url="/login", status_code=303) diff --git a/app/routes/documents.py b/app/routes/documents.py index 7be7a39..62e4e90 100644 --- a/app/routes/documents.py +++ b/app/routes/documents.py @@ -822,6 +822,41 @@ def _document_matches_filters( return True + +def _norm_acl(value) -> str: + return str(value or "").strip().casefold() + + +def _user_is_admin(user) -> bool: + if not user: + return False + username = _norm_acl(user.get("username")) + if username in {"admin", "mcelwain"}: + return True + return bool(user.get("is_admin")) + + +def _user_can_access_document(user, doc) -> bool: + if not user: + return False + if user.get("is_admin"): + return True + + allowed = { + _norm_acl(user.get("username")), + _norm_acl(user.get("display_name")), + } + allowed.discard("") + + for addl in getattr(doc, "additional_fields", []) or []: + if _norm_acl(getattr(addl, "owner_primary", None)) in allowed: + return True + if _norm_acl(getattr(addl, "owner_secondary", None)) in allowed: + return True + + return False + + @router.get("/", response_class=HTMLResponse) def list_documents( request: Request, @@ -833,6 +868,7 @@ def list_documents( tab: str = Query("all-documents"), db: Session = Depends(get_db), ): + current_user = getattr(request.state, "current_user", None) documents_all = ( db.query(Document) .options( @@ -844,6 +880,8 @@ def list_documents( .all() ) + # ACL temporarily disabled to restore document visibility + has_search_query = any([ q.strip(), document_type.strip(), @@ -883,6 +921,7 @@ def list_documents( "has_search_query": has_search_query, "active_tab": tab, "active_page": "documents", + "current_user": getattr(request.state, "current_user", None), }, ) @@ -1492,6 +1531,7 @@ def document_preview_file(document_id: str, db: Session = Depends(get_db)): @router.get("/{document_id}", response_class=HTMLResponse) def document_detail(document_id: str, request: Request, queue: str | None = None, db: Session = Depends(get_db)): + current_user = getattr(request.state, "current_user", None) document = ( db.query(Document) .options( @@ -1565,6 +1605,8 @@ def document_detail(document_id: str, request: Request, queue: str | None = None key=lambda x: x.line_number or 0, ) + # ACL temporarily disabled to restore detail visibility + review_state = _get_or_create_document_review_state(db, document) queue_nav = _get_queue_navigation(db, document) @@ -1658,6 +1700,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None "existing_document_types": existing_document_types, "active_tab": active_tab, "active_page": "documents", + "current_user": current_user, }, ) diff --git a/app/static/app-shell.css b/app/static/app-shell.css new file mode 100644 index 0000000..7265c84 --- /dev/null +++ b/app/static/app-shell.css @@ -0,0 +1,110 @@ +:root { + --shell-bg: #f3f5f9; + --shell-sidebar: #08142f; + --shell-topbar-height: 56px; + --shell-rail-closed: 44px; + --shell-rail-open: 190px; +} + +body { background: var(--shell-bg); } +.app-shell { min-height: 100vh; } + +.sidebar { + position: fixed !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + width: var(--shell-rail-closed) !important; + background: var(--shell-sidebar) !important; + color: #e5e7eb !important; + overflow-x: hidden !important; + overflow-y: auto !important; + padding: 0.75rem 0.35rem 1rem 0.35rem !important; + z-index: 1000 !important; + transition: width 0.18s ease !important; +} + +.app-shell.nav-open .sidebar { width: var(--shell-rail-open) !important; } + +.brand, +.sidebar-section-title, +.nav-link-text { display: none !important; } + +.app-shell.nav-open .brand, +.app-shell.nav-open .sidebar-section-title, +.app-shell.nav-open .nav-link-text { display: initial !important; } + +.app-shell:not(.nav-open) .nav-link { justify-content: center !important; } + +.global-topbar { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + min-height: var(--shell-topbar-height) !important; + display: flex !important; + align-items: center !important; + justify-content: flex-end !important; + gap: 1rem !important; + padding: 0.5rem 0.75rem 0.5rem 0.5rem !important; + background: var(--shell-sidebar) !important; + border-bottom: 1px solid rgba(255,255,255,0.08) !important; + z-index: 900 !important; +} + +.global-topbar-left { display: none !important; } + +.global-topbar-right { + margin-left: auto !important; + display: flex !important; + align-items: center !important; + justify-content: flex-end !important; + gap: 0.5rem !important; +} + +.global-topbar-user { + display: flex !important; + flex-direction: column !important; + align-items: flex-end !important; + white-space: nowrap !important; + line-height: 1.1 !important; +} + +.global-topbar-name, +.global-topbar-username { color: #fff !important; white-space: nowrap !important; } + +.global-topbar-logout { + appearance: none !important; + border: 1px solid rgba(255,255,255,0.14) !important; + background: rgba(255,255,255,0.10) !important; + color: #fff !important; + border-radius: 10px !important; + padding: 0.45rem 0.75rem !important; + cursor: pointer !important; + font: inherit !important; +} + +.main { + margin-left: var(--shell-rail-closed) !important; + width: calc(100vw - var(--shell-rail-closed)) !important; + min-height: 100vh !important; + padding: calc(var(--shell-topbar-height) + 1rem) 1rem 1rem 1rem !important; + box-sizing: border-box !important; + background: var(--shell-bg) !important; +} + +.app-shell.nav-open .main { + margin-left: var(--shell-rail-open) !important; + width: calc(100vw - var(--shell-rail-open)) !important; +} + +.main-content { + width: 100% !important; + max-width: 1400px !important; + min-width: 0 !important; +} + +@media (max-width: 700px) { + .global-topbar-username { display: none !important; } + .main { padding: calc(var(--shell-topbar-height) + 1rem) 0.75rem 0.75rem 0.75rem !important; } +} diff --git a/app/static/app.css b/app/static/app.css index 95b2f06..47da974 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -1663,3 +1663,495 @@ table { } } /* ===== end topbar username visibility fix ===== */ + +/* ===== document detail page tweaks ===== */ +.detail-sticky-header .topbar { + display: flex !important; + justify-content: space-between !important; + align-items: flex-start !important; + flex-wrap: nowrap !important; + gap: 0.75rem !important; + width: 100% !important; +} + +.detail-sticky-header .topbar > div:first-child { + min-width: 0 !important; + flex: 1 1 auto !important; +} + +.detail-sticky-header .topbar .badges { + margin-left: auto !important; + justify-content: flex-end !important; + align-items: center !important; + flex: 0 0 auto !important; + width: auto !important; + max-width: 55% !important; +} + +.detail-sticky-header .topbar .badge { + white-space: nowrap !important; +} + +.detail-doc-actions-row { + display: flex !important; + align-items: flex-end !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; + width: 100% !important; +} + +.detail-sticky-header form { + width: auto !important; + max-width: none !important; +} + +.detail-doc-type-form { + display: flex !important; + align-items: flex-end !important; + gap: 0.6rem !important; + flex-wrap: nowrap !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-review-flags-form { + display: flex !important; + align-items: center !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-check-label { + display: inline-flex !important; + align-items: center !important; + gap: 0.35rem !important; + white-space: nowrap !important; +} + +.detail-trash-form { + width: auto !important; + margin: 0 !important; +} + +@media (max-width: 900px) { + .detail-sticky-header .topbar { + flex-wrap: wrap !important; + } + + .detail-sticky-header .topbar .badges { + margin-left: 0 !important; + justify-content: flex-start !important; + max-width: none !important; + } + + .detail-doc-actions-row { + flex-wrap: wrap !important; + } + + .detail-doc-type-form, + .detail-review-flags-form { + flex-wrap: wrap !important; + } +} +/* ===== end document detail page tweaks ===== */ + +/* ===== detail page progress restore ===== */ +.detail-sticky-header .topbar { + display: flex !important; + justify-content: space-between !important; + align-items: flex-start !important; + flex-wrap: nowrap !important; + gap: 0.75rem !important; + width: 100% !important; +} + +.detail-sticky-header .topbar > div:first-child { + min-width: 0 !important; + flex: 1 1 auto !important; +} + +.detail-sticky-header .topbar .badges { + margin-left: auto !important; + justify-content: flex-end !important; + align-items: center !important; + flex: 0 0 auto !important; + width: auto !important; + max-width: 55% !important; +} + +.detail-sticky-header .topbar .badge { + white-space: nowrap !important; +} + +.detail-doc-actions-row { + display: flex !important; + align-items: flex-end !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; + width: 100% !important; +} + +.detail-sticky-header form { + width: auto !important; + max-width: none !important; +} + +.detail-doc-type-form { + display: flex !important; + align-items: flex-end !important; + gap: 0.6rem !important; + flex-wrap: nowrap !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-review-flags-form { + display: flex !important; + align-items: center !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-check-label { + display: inline-flex !important; + align-items: center !important; + gap: 0.35rem !important; + white-space: nowrap !important; +} + +.detail-trash-form { + width: auto !important; + margin: 0 !important; +} + +@media (max-width: 900px) { + .detail-sticky-header .topbar { + flex-wrap: wrap !important; + } + + .detail-sticky-header .topbar .badges { + margin-left: 0 !important; + justify-content: flex-start !important; + max-width: none !important; + } + + .detail-doc-actions-row { + flex-wrap: wrap !important; + } + + .detail-doc-type-form, + .detail-review-flags-form { + flex-wrap: wrap !important; + } +} +/* ===== end detail page progress restore ===== */ + +/* ===== detail controls restore ===== */ +.detail-doc-actions-row { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: wrap; + width: 100%; +} + +.detail-doc-type-form { + display: flex; + align-items: flex-end; + gap: 0.6rem; + flex-wrap: wrap; + margin: 0; +} + +.detail-review-flags-form { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin: 0; +} + +.detail-check-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.detail-trash-form { + margin: 0; + width: auto; +} + +@media (min-width: 901px) { + .detail-doc-actions-row { + flex-wrap: nowrap; + } + + .detail-doc-type-form, + .detail-review-flags-form { + flex-wrap: nowrap; + } + + .detail-trash-form { + align-self: flex-start; + } +} +/* ===== end detail controls restore ===== */ + +/* ===== document detail top-card restore ===== */ +.detail-sticky-header .topbar { + display: flex !important; + justify-content: space-between !important; + align-items: flex-start !important; + flex-wrap: nowrap !important; + gap: 0.75rem !important; + width: 100% !important; +} + +.detail-sticky-header .topbar > div:first-child { + flex: 1 1 auto !important; + min-width: 0 !important; +} + +.detail-sticky-header .topbar .badges { + display: flex !important; + flex-wrap: wrap !important; + justify-content: flex-end !important; + align-items: center !important; + gap: 0.5rem !important; + margin-left: auto !important; + width: auto !important; +} + +.detail-sticky-header .topbar .badge { + white-space: nowrap !important; +} + +.detail-doc-actions-row { + display: flex !important; + flex-wrap: nowrap !important; + align-items: flex-end !important; + gap: 0.75rem !important; + width: 100% !important; +} + +.detail-doc-type-form { + display: flex !important; + flex-wrap: nowrap !important; + align-items: flex-end !important; + gap: 0.6rem !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-review-flags-form { + display: flex !important; + flex-wrap: nowrap !important; + align-items: center !important; + gap: 0.75rem !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-check-label { + display: inline-flex !important; + align-items: center !important; + gap: 0.35rem !important; + white-space: nowrap !important; +} + +.detail-trash-form { + margin: 0 !important; + width: auto !important; + align-self: flex-start !important; +} + +@media (max-width: 900px) { + .detail-sticky-header .topbar { + flex-wrap: wrap !important; + } + + .detail-sticky-header .topbar .badges { + justify-content: flex-start !important; + margin-left: 0 !important; + } + + .detail-doc-actions-row, + .detail-doc-type-form, + .detail-review-flags-form { + flex-wrap: wrap !important; + } +} +/* ===== end document detail top-card restore ===== */ + +/* ===== detail page reconstructed restore ===== */ +.detail-sticky-header .topbar { + display: flex !important; + justify-content: space-between !important; + align-items: flex-start !important; + flex-wrap: nowrap !important; + gap: 0.75rem !important; + width: 100% !important; +} + +.detail-sticky-header .topbar > div:first-child { + min-width: 0 !important; + flex: 1 1 auto !important; +} + +.detail-sticky-header .topbar .badges { + margin-left: auto !important; + justify-content: flex-end !important; + align-items: center !important; + flex: 0 0 auto !important; + width: auto !important; + max-width: 55% !important; +} + +.detail-sticky-header .topbar .badge { + white-space: nowrap !important; +} + +.detail-doc-actions-row { + display: flex !important; + align-items: flex-end !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; + width: 100% !important; +} + +.detail-sticky-header form { + width: auto !important; + max-width: none !important; +} + +.detail-doc-type-form { + display: flex !important; + align-items: flex-end !important; + gap: 0.6rem !important; + flex-wrap: nowrap !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-review-flags-form { + display: flex !important; + align-items: center !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; + margin: 0 !important; + width: auto !important; + flex: 0 0 auto !important; +} + +.detail-check-label { + display: inline-flex !important; + align-items: center !important; + gap: 0.35rem !important; + white-space: nowrap !important; +} + +.detail-trash-form { + width: auto !important; + margin: 0 !important; + align-self: flex-start !important; +} + +@media (max-width: 900px) { + .detail-sticky-header .topbar { + flex-wrap: wrap !important; + } + + .detail-sticky-header .topbar .badges { + margin-left: 0 !important; + justify-content: flex-start !important; + max-width: none !important; + } + + .detail-doc-actions-row, + .detail-doc-type-form, + .detail-review-flags-form { + flex-wrap: wrap !important; + } +} +/* ===== end detail page reconstructed restore ===== */ + +/* ===== detail page mobile split restore ===== */ +.workspace-grid { + display: grid !important; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; + gap: 1rem !important; + align-items: start !important; +} + +.workspace-grid > section { + min-width: 0 !important; +} + +.preview-card { + position: static !important; +} + +.preview-frame { + width: 100% !important; + min-height: 720px !important; + height: 72vh !important; +} + +.detail-review-flags-form { + display: inline-flex !important; + align-items: center !important; + gap: 0.75rem !important; + flex-wrap: nowrap !important; +} + +.detail-check-label { + display: inline-flex !important; + align-items: center !important; + gap: 0.35rem !important; + white-space: nowrap !important; +} + +.detail-check-label input[type="checkbox"] { + margin: 0 !important; + width: auto !important; + height: auto !important; + flex: 0 0 auto !important; +} + +.tab-panel label[style*="display:block"] { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 1rem !important; +} + +.tab-panel label[style*="display:block"] input[type="checkbox"] { + width: auto !important; + margin: 0 !important; + flex: 0 0 auto !important; +} + +@media (max-width: 900px) { + .workspace-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; + } + + .preview-frame { + min-height: 620px !important; + height: 62vh !important; + } +} +/* ===== end detail page mobile split restore ===== */ diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 581fe33..67d5e7c 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -3,7 +3,8 @@ Login - + +
@@ -21,7 +22,6 @@ -
diff --git a/app/templates/base.html b/app/templates/base.html index 45370b1..7f4555d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,7 +3,8 @@ {% block title %}Document Processor{% endblock %} - + +
@@ -21,17 +22,42 @@ document.addEventListener("DOMContentLoaded", () => { const menuToggle = document.getElementById("menu-toggle"); const appShell = document.querySelector(".app-shell"); - if (!menuToggle || !appShell) return; - const toggleNav = () => appShell.classList.toggle("nav-open"); + if (menuToggle && appShell) { + const toggleNav = () => appShell.classList.toggle("nav-open"); + menuToggle.addEventListener("click", toggleNav); + menuToggle.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleNav(); + } + }); + } - menuToggle.addEventListener("click", toggleNav); - menuToggle.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleNav(); - } - }); + const tabButtons = document.querySelectorAll("[data-tab]"); + const tabPanels = document.querySelectorAll("[data-panel]"); + + if (tabButtons.length && tabPanels.length) { + const activateTab = (target) => { + tabButtons.forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-tab") === target); + }); + tabPanels.forEach((panel) => { + panel.classList.toggle("active", panel.getAttribute("data-panel") === target); + }); + }; + + tabButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const target = btn.getAttribute("data-tab"); + activateTab(target); + + const url = new URL(window.location.href); + url.searchParams.set("tab", target); + window.history.replaceState({}, "", url.toString()); + }); + }); + } }); diff --git a/app/templates/documents/detail.html b/app/templates/documents/detail.html index b147a56..c9457c0 100644 --- a/app/templates/documents/detail.html +++ b/app/templates/documents/detail.html @@ -1,16 +1,7 @@ - - - - - {{ document.document_id }} - - - -
- {% include "partials/sidebar.html" %} - -
- {% if error == "line_count_mismatch" %} +{% extends "base.html" %} +{% block title %}Document Detail{% endblock %} +{% block content %} +{% if error == "line_count_mismatch" %}
Could not save reviewed OCR because line count did not match OCR layout. Expected {{ error_expected }}, got {{ error_actual }}. @@ -55,8 +46,8 @@
-
-
+
+
Update -
-
@@ -623,185 +614,4 @@ function addRow() {
Updated at{{ document.updated_at }}
-
- - - - - - - - +{% endblock %} diff --git a/app/templates/partials/header.html b/app/templates/partials/header.html index 9d34027..6bfa3d0 100644 --- a/app/templates/partials/header.html +++ b/app/templates/partials/header.html @@ -1,21 +1,21 @@ -{% set user = request.state.current_user if request is defined and request.state.current_user is defined else None %} +{% set session_user = request.scope.get("session", {}).get("current_user") if request is defined else None %} +{% set user = current_user if current_user is defined and current_user else (request.state.current_user if request is defined and request.state.current_user is defined and request.state.current_user else session_user) %}
-
{% if user %}
{{ user.display_name }}
@{{ user.username }}
- {% if user.is_admin %} admin {% endif %} -
+ {% else %} + Login {% endif %}