From 1b38396a2c31d51e1eab34960767f36729d2b5b3 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 30 Aug 2026 16:02:29 +0200 Subject: [PATCH] Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite - auth.py/auth_routes.py: Login Flow v2 (verified against real NC source/docs) - no OAuth2 client registration needed, backend polls server-side so no CORS issues. Sessions are hashed-token cookies; NC app passwords encrypted at rest (Fernet). Every /api/* route guarded by a blueprint-wide before_request, not per-route decorators, so future routes are protected by default. - receipts/groups scoped per owner_nc_user_id; cross-user access 404s. - nc_client/cospend_client take (username, app_password) per call instead of one shared global credential - each user's uploads/shares/ bills now happen as themselves. - Frontend: LoginGate component drives the login flow (open NC login in a new tab, poll our backend, done). - Merged the old separate review step into the split screen, redesigned with Tailwind (was unstyled/broken), default-excluded-per-item splitting with one-click "include everyone" fixed, DD.MM.YYYY date field, receipt-icon branding. - Dropped LLM bounding-box highlighting - unreliable on real receipts, plain photo upload instead. - Only mention who a bill is split with in its title when there's more than one bill off the same receipt to disambiguate. --- backend/.env.example | 23 +- backend/app/__init__.py | 13 +- backend/app/auth.py | 213 +++++++++ backend/app/auth_routes.py | 54 +++ backend/app/config.py | 19 +- backend/app/cospend_client.py | 26 +- backend/app/highlight.py | 32 -- backend/app/llm_client.py | 9 +- backend/app/nc_client.py | 41 +- backend/app/routes.py | 124 +++-- backend/app/schema.sql | 21 +- backend/requirements.txt | 1 + frontend/package-lock.json | 590 ++++++++++++++++++++++++ frontend/package.json | 3 + frontend/public/favicon.svg | 2 +- frontend/public/pwa-192x192.png | Bin 0 -> 4781 bytes frontend/public/pwa-512x512.png | Bin 0 -> 13684 bytes frontend/src/App.css | 199 -------- frontend/src/App.tsx | 63 +-- frontend/src/api.ts | 55 ++- frontend/src/assets/hero.png | Bin 13057 -> 0 bytes frontend/src/assets/react.svg | 1 - frontend/src/assets/vite.svg | 1 - frontend/src/components/CaptureStep.tsx | 44 +- frontend/src/components/GroupStep.tsx | 488 ++++++++++++++------ frontend/src/components/LoginGate.tsx | 115 +++++ frontend/src/components/ReviewStep.tsx | 107 ----- frontend/src/components/SummaryStep.tsx | 31 +- frontend/src/index.css | 108 +---- frontend/src/types.ts | 13 - frontend/vite.config.ts | 3 + 31 files changed, 1653 insertions(+), 746 deletions(-) create mode 100644 backend/app/auth.py create mode 100644 backend/app/auth_routes.py delete mode 100644 backend/app/highlight.py create mode 100644 frontend/public/pwa-192x192.png create mode 100644 frontend/public/pwa-512x512.png delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/assets/hero.png delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/LoginGate.tsx delete mode 100644 frontend/src/components/ReviewStep.tsx diff --git a/backend/.env.example b/backend/.env.example index 32aa7fc..dbe5d0a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,12 +1,13 @@ # Copy to .env and fill in. Never commit .env. # --- Nextcloud --- +# Server address only - each user logs in themselves via Nextcloud Login +# Flow v2 (see /auth/login/start); there's no shared NC_USERNAME/APP_PASSWORD +# any more. NC_BASE_URL=https://cloud.dominik-roth.eu -NC_USERNAME=dodox -# Settings -> Security -> "Devices & Sessions" -> create app password -NC_APP_PASSWORD= -# Folder (relative to the user's files root) receipt images get uploaded to. -# Created automatically on first upload if missing (each path segment). +# Folder (relative to each user's own files root) receipt images get +# uploaded to. Created automatically on first upload if missing (each path +# segment). NC_UPLOAD_FOLDER=Documents/Cospend/Assets # --- Cospend --- @@ -29,3 +30,15 @@ LLM_MODEL= # --- Flask --- FLASK_SECRET_KEY=dev-change-me DATABASE_PATH=instance/wgbill.sqlite3 + +# --- Auth --- +# Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Encrypts stored Nextcloud app passwords at rest. Required - the app +# refuses to store/read a token without it. Keep this secret; losing it +# means every logged-in user has to log in again. +TOKEN_ENCRYPTION_KEY= +# Idle session lifetime in days (sliding - refreshed on each use). +SESSION_TTL_DAYS=30 +# Cookies need Secure (HTTPS-only) for any real deployment. Only set to +# false for plain-http localhost dev. +SESSION_COOKIE_SECURE=true diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 6d868ab..08deea3 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -15,13 +15,22 @@ def create_app() -> Flask: os.makedirs(Config.UPLOAD_DIR, exist_ok=True) # Frontend runs on a different origin (vite dev server) during - # development; lock this down to that origin in production. - CORS(app, resources={r"/api/*": {"origins": Config.CORS_ORIGIN}}) + # development; lock this down to that origin in production. Auth now + # relies on a session cookie, so credentials must be allowed - and with + # supports_credentials, the origin allowlist can't be "*", it has to be + # this one explicit origin (flask-cors enforces that). + CORS( + app, + resources={r"/api/*": {"origins": Config.CORS_ORIGIN}, r"/auth/*": {"origins": Config.CORS_ORIGIN}}, + supports_credentials=True, + ) init_db(Config.DATABASE_PATH) + from .auth_routes import bp as auth_bp from .routes import bp as api_bp app.register_blueprint(api_bp, url_prefix="/api") + app.register_blueprint(auth_bp, url_prefix="/auth") return app diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..1583173 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,213 @@ +"""Login via Nextcloud's Login Flow v2 (the same mechanism NC's own desktop/ +mobile clients use to obtain an app password without ever seeing a +third-party app's code touch the user's real password). + +Flow, doc-verified against docs.nextcloud.com/server/stable/developer_manual +/client_apis/LoginFlow/index.html: + 1. POST {NC_BASE_URL}/index.php/login/v2 (no auth) -> + {poll: {token, endpoint}, login: } + 2. User opens `login`, authenticates directly with Nextcloud, approves. + 3. POST {poll.endpoint} with body token= -> 404 while pending, + 200 once (single-use) with {server, loginName, appPassword} once + granted. Token valid 20 minutes. + +Because our backend does the polling (not browser JS), none of the CORS +issues that rule out a pure-frontend NC login apply here - the browser only +ever does a full-page navigation to NC's own login page and back. +""" + +from __future__ import annotations + +import hashlib +import secrets +import time +from datetime import datetime, timedelta +from functools import wraps + +import requests +from cryptography.fernet import Fernet, InvalidToken +from flask import g, jsonify, request + +from .config import Config +from .db import get_conn + +# In-memory store for login flows in progress: flow_id -> {poll_token, +# poll_endpoint, created_at}. Short-lived (20 min NC-side) and low-stakes if +# lost on a backend restart (the user just retries the login button) - not +# worth a DB table. +_pending_flows: dict[str, dict] = {} +_FLOW_TTL_SECONDS = 20 * 60 + + +def _fernet() -> Fernet: + if not Config.TOKEN_ENCRYPTION_KEY: + raise RuntimeError( + "TOKEN_ENCRYPTION_KEY is not set (see .env.example) - refusing to " + "store or read an NC app password without it" + ) + return Fernet(Config.TOKEN_ENCRYPTION_KEY.encode()) + + +def _hash_session_id(session_id: str) -> str: + return hashlib.sha256(session_id.encode()).hexdigest() + + +# ------------------------------------------------------------ login flow + +def start_login_flow() -> dict: + """Kicks off a Login Flow v2 against NC. Returns {flow_id, login_url}.""" + resp = requests.post(f"{Config.NC_BASE_URL}/index.php/login/v2", timeout=15) + resp.raise_for_status() + data = resp.json() + + flow_id = secrets.token_urlsafe(24) + _pending_flows[flow_id] = { + "poll_token": data["poll"]["token"], + "poll_endpoint": data["poll"]["endpoint"], + "created_at": time.time(), + } + _gc_expired_flows() + return {"flow_id": flow_id, "login_url": data["login"]} + + +def poll_login_flow(flow_id: str) -> dict: + """Returns {"status": "pending"} | {"status": "expired"} | + {"status": "done", "nc_user_id": str, "session_id": str}. + + On success, provisions/updates the user row and creates a new session - + the caller still has to actually set the cookie on the response. + """ + flow = _pending_flows.get(flow_id) + if flow is None: + return {"status": "expired"} + if time.time() - flow["created_at"] > _FLOW_TTL_SECONDS: + del _pending_flows[flow_id] + return {"status": "expired"} + + resp = requests.post( + flow["poll_endpoint"], data={"token": flow["poll_token"]}, timeout=15 + ) + if resp.status_code == 404: + return {"status": "pending"} + resp.raise_for_status() + + # Single-use - NC only returns this once, so this flow is done either way. + del _pending_flows[flow_id] + + granted = resp.json() + nc_user_id = granted["loginName"] + app_password = granted["appPassword"] + + encrypted = _fernet().encrypt(app_password.encode()) + with get_conn() as conn: + conn.execute( + """INSERT INTO users (nc_user_id, nc_app_password_encrypted) + VALUES (?, ?) + ON CONFLICT(nc_user_id) DO UPDATE SET + nc_app_password_encrypted = excluded.nc_app_password_encrypted, + last_login_at = datetime('now')""", + (nc_user_id, encrypted), + ) + + session_id = create_session(nc_user_id) + return {"status": "done", "nc_user_id": nc_user_id, "session_id": session_id} + + +def _gc_expired_flows() -> None: + now = time.time() + expired = [fid for fid, f in _pending_flows.items() if now - f["created_at"] > _FLOW_TTL_SECONDS] + for fid in expired: + del _pending_flows[fid] + + +# --------------------------------------------------------------- sessions + +def create_session(nc_user_id: str) -> str: + """Returns the raw session id to set as the cookie value.""" + session_id = secrets.token_urlsafe(32) + expires_at = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat() + with get_conn() as conn: + conn.execute( + "INSERT INTO sessions (session_id_hash, nc_user_id, expires_at) VALUES (?, ?, ?)", + (_hash_session_id(session_id), nc_user_id, expires_at), + ) + return session_id + + +def resolve_session(session_id: str) -> str | None: + """Returns the nc_user_id for a valid, non-expired session, refreshing + its expiry (sliding session), or None if invalid/expired.""" + session_hash = _hash_session_id(session_id) + with get_conn() as conn: + row = conn.execute( + "SELECT nc_user_id, expires_at FROM sessions WHERE session_id_hash = ?", + (session_hash,), + ).fetchone() + if row is None: + return None + if datetime.fromisoformat(row["expires_at"]) < datetime.utcnow(): + conn.execute("DELETE FROM sessions WHERE session_id_hash = ?", (session_hash,)) + return None + + new_expiry = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat() + conn.execute( + "UPDATE sessions SET expires_at = ? WHERE session_id_hash = ?", + (new_expiry, session_hash), + ) + return row["nc_user_id"] + + +def destroy_session(session_id: str) -> None: + with get_conn() as conn: + conn.execute( + "DELETE FROM sessions WHERE session_id_hash = ?", (_hash_session_id(session_id),) + ) + + +def get_app_password(nc_user_id: str) -> str: + with get_conn() as conn: + row = conn.execute( + "SELECT nc_app_password_encrypted FROM users WHERE nc_user_id = ?", (nc_user_id,) + ).fetchone() + if row is None: + raise RuntimeError(f"no stored credentials for {nc_user_id!r}") + try: + return _fernet().decrypt(bytes(row["nc_app_password_encrypted"])).decode() + except InvalidToken as exc: + raise RuntimeError( + "stored app password could not be decrypted - TOKEN_ENCRYPTION_KEY " + "changed or is wrong" + ) from exc + + +# ------------------------------------------------------------- decorator + +def enforce_login(): + """Populates g.nc_user_id/g.nc_app_password from the session cookie, or + returns a 401 response if there isn't a valid one. Used both as a + blueprint-wide `before_request` (so any route added to that blueprint is + protected by default, not only ones someone remembered to decorate) and + directly by the `login_required` decorator for routes registered + elsewhere. + """ + session_id = request.cookies.get(Config.SESSION_COOKIE_NAME) + nc_user_id = resolve_session(session_id) if session_id else None + if nc_user_id is None: + return jsonify(error="not logged in"), 401 + g.nc_user_id = nc_user_id + g.nc_app_password = get_app_password(nc_user_id) + return None + + +def login_required(view): + """Per-route variant of enforce_login(), for routes on a blueprint that + isn't (or shouldn't be) guarded wholesale.""" + + @wraps(view) + def wrapped(*args, **kwargs): + rejection = enforce_login() + if rejection is not None: + return rejection + return view(*args, **kwargs) + + return wrapped diff --git a/backend/app/auth_routes.py b/backend/app/auth_routes.py new file mode 100644 index 0000000..952eb21 --- /dev/null +++ b/backend/app/auth_routes.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from flask import Blueprint, jsonify, request + +from . import auth +from .config import Config + +bp = Blueprint("auth", __name__) + + +@bp.post("/login/start") +def login_start(): + try: + result = auth.start_login_flow() + except Exception as exc: # noqa: BLE001 + return jsonify(error=f"could not start login: {exc}"), 502 + return jsonify(result) + + +@bp.get("/login/poll") +def login_poll(): + flow_id = request.args.get("flow_id", "") + if not flow_id: + return jsonify(error="flow_id is required"), 400 + + try: + result = auth.poll_login_flow(flow_id) + except Exception as exc: # noqa: BLE001 + return jsonify(error=f"login poll failed: {exc}"), 502 + + if result["status"] != "done": + return jsonify(status=result["status"]) + + resp = jsonify(status="done", nc_user_id=result["nc_user_id"]) + resp.set_cookie( + Config.SESSION_COOKIE_NAME, + result["session_id"], + max_age=Config.SESSION_TTL_DAYS * 24 * 3600, + httponly=True, + secure=Config.SESSION_COOKIE_SECURE, + samesite="Lax", + path="/", + ) + return resp + + +@bp.post("/logout") +def logout(): + session_id = request.cookies.get(Config.SESSION_COOKIE_NAME) + if session_id: + auth.destroy_session(session_id) + resp = jsonify(status="ok") + resp.delete_cookie(Config.SESSION_COOKIE_NAME, path="/") + return resp diff --git a/backend/app/config.py b/backend/app/config.py index e9cee9c..3f42f55 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,10 +17,10 @@ class Config: CORS_ORIGIN = os.environ.get("CORS_ORIGIN", "http://localhost:5173") + # Server address only - who's calling is now per logged-in user (see + # auth.py), not a single shared NC_USERNAME/NC_APP_PASSWORD. NC_BASE_URL = os.environ.get("NC_BASE_URL", "").rstrip("/") - NC_USERNAME = os.environ.get("NC_USERNAME", "") - NC_APP_PASSWORD = os.environ.get("NC_APP_PASSWORD", "") - NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "wgBill").strip("/") + NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "Documents/Cospend/Assets").strip("/") # Optional - pre-selects a project in the UI; the app lists all of the # user's Cospend projects via the API either way, so this isn't required. @@ -29,3 +29,16 @@ class Config: LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "").rstrip("/") LLM_API_KEY = os.environ.get("LLM_API_KEY", "") LLM_MODEL = os.environ.get("LLM_MODEL", "") + + # Fernet key (Fernet.generate_key()) used to encrypt stored NC app + # passwords at rest. Required in production; a request-time error is + # raised if missing so this can't be silently skipped. + TOKEN_ENCRYPTION_KEY = os.environ.get("TOKEN_ENCRYPTION_KEY", "") + + SESSION_COOKIE_NAME = "wgbill_session" + # Sessions are sliding - refreshed on use, so an active user never gets + # logged out; an idle one expires after this many days. + SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30")) + # Cookies need Secure (HTTPS-only) in any real deployment; only disable + # for plain-http local dev. + SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true" diff --git a/backend/app/cospend_client.py b/backend/app/cospend_client.py index 965ea65..f02edf5 100644 --- a/backend/app/cospend_client.py +++ b/backend/app/cospend_client.py @@ -1,6 +1,7 @@ -"""Cospend's authenticated (NC-login) API - same app-password auth as -nc_client. This is an OCS API (same family as the Share API), confirmed -against the real routes/controller source in julien-nc/cospend-nc: +"""Cospend's authenticated (NC-login) API - same per-user app-password auth +as nc_client (see auth.py). This is an OCS API (same family as the Share +API), confirmed against the real routes/controller source in +julien-nc/cospend-nc: appinfo/routes.php ('ocs' section): GET /api/{v}/projects -> api#getLocalProjects @@ -28,10 +29,6 @@ from .config import Config _HEADERS = {"OCS-APIRequest": "true"} -def _auth() -> tuple[str, str]: - return (Config.NC_USERNAME, Config.NC_APP_PASSWORD) - - def _base(project_id: str | None = None) -> str: root = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/cospend/api/v1" if project_id is None: @@ -39,11 +36,11 @@ def _base(project_id: str | None = None) -> str: return f"{root}/projects/{project_id}" -def get_projects() -> list[dict[str, Any]]: +def get_projects(username: str, app_password: str) -> list[dict[str, Any]]: """Lists Cospend projects visible to the authenticated user.""" resp = requests.get( f"{_base()}/projects", - auth=_auth(), + auth=(username, app_password), headers=_HEADERS, params={"format": "json"}, timeout=15, @@ -52,10 +49,10 @@ def get_projects() -> list[dict[str, Any]]: return resp.json()["ocs"]["data"] -def get_members(project_id: str) -> list[dict[str, Any]]: +def get_members(username: str, app_password: str, project_id: str) -> list[dict[str, Any]]: resp = requests.get( f"{_base(project_id)}/members", - auth=_auth(), + auth=(username, app_password), headers=_HEADERS, params={"format": "json"}, timeout=15, @@ -65,6 +62,8 @@ def get_members(project_id: str) -> list[dict[str, Any]]: def create_bill( + username: str, + app_password: str, *, project_id: str, what: str, @@ -82,7 +81,7 @@ def create_bill( """ resp = requests.post( f"{_base(project_id)}/bills", - auth=_auth(), + auth=(username, app_password), headers=_HEADERS, params={"format": "json"}, json={ @@ -92,6 +91,9 @@ def create_bill( "payedFor": ",".join(str(i) for i in ower_ids), "comment": comment, "date": date, # YYYY-MM-DD, the receipt's issue date, not today + # required by LocalProjectService::createBill (400s without it); + # 'n' = FREQUENCY_NO, i.e. this bill doesn't repeat. + "repeat": "n", }, timeout=15, ) diff --git a/backend/app/highlight.py b/backend/app/highlight.py deleted file mode 100644 index 0209c53..0000000 --- a/backend/app/highlight.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Draws highlight boxes over selected items on the original receipt image.""" - -from __future__ import annotations - -import io - -from PIL import Image, ImageDraw - -_BOX_COLOR = (255, 210, 0) # translucent yellow highlighter look -_BOX_WIDTH = 4 - - -def highlight_items(image_bytes: bytes, bboxes: list[list[float]]) -> bytes: - """`bboxes` are normalized [x, y, w, h] (0-1). Returns JPEG bytes.""" - base = Image.open(io.BytesIO(image_bytes)).convert("RGBA") - overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) - draw = ImageDraw.Draw(overlay) - w, h = base.size - - for bbox in bboxes: - if not bbox or len(bbox) != 4: - continue - x, y, bw, bh = bbox - left, top = x * w, y * h - right, bottom = (x + bw) * w, (y + bh) * h - draw.rectangle([left, top, right, bottom], fill=(*_BOX_COLOR, 70)) - draw.rectangle([left, top, right, bottom], outline=(*_BOX_COLOR, 255), width=_BOX_WIDTH) - - combined = Image.alpha_composite(base, overlay).convert("RGB") - out = io.BytesIO() - combined.save(out, format="JPEG", quality=90) - return out.getvalue() diff --git a/backend/app/llm_client.py b/backend/app/llm_client.py index 7377002..3265531 100644 --- a/backend/app/llm_client.py +++ b/backend/app/llm_client.py @@ -1,4 +1,4 @@ -"""Receipt -> [{id, label, price, bbox}] via any OpenAI-compatible chat +"""Receipt -> [{id, label, price}] via any OpenAI-compatible chat completions endpoint (works unmodified with OpenAI; point LLM_BASE_URL at Gemini's OpenAI-compat layer to use that instead, no code change needed). """ @@ -23,16 +23,12 @@ Respond with ONLY a JSON object, no prose, no markdown fences, shaped like: "store_name": "", "date": "", "items": [ - {"label": "", "price": , \ -"bbox": [x, y, w, h]} + {"label": "", "price": } ] } - price is the item's price in the receipt's currency, as a plain number \ (no currency symbol). -- bbox is the item's approximate bounding box on the image, normalized to \ -0-1 (x, y = top-left corner; w, h = width/height as a fraction of the \ -image). Best effort is fine. - Skip subtotal/tax/total lines, only real purchased items. - If a line item's price is unclear, make your best guess rather than \ omitting it. @@ -79,7 +75,6 @@ def extract_receipt(image_bytes: bytes, mime_type: str = "image/jpeg") -> dict: "id": str(uuid.uuid4()), "label": str(entry.get("label", "")).strip(), "price": float(entry.get("price", 0) or 0), - "bbox": entry.get("bbox") or None, } ) diff --git a/backend/app/nc_client.py b/backend/app/nc_client.py index 89d2d5f..589372b 100644 --- a/backend/app/nc_client.py +++ b/backend/app/nc_client.py @@ -1,7 +1,9 @@ """Thin wrapper around the bits of Nextcloud's WebDAV and OCS Share API we need. -Auth is HTTP Basic with an app password (Settings -> Security -> Devices & -Sessions -> create app password), never the account's real password. +Auth is HTTP Basic with an app password obtained per-user via Login Flow v2 +(see auth.py) - callers pass (username, app_password) explicitly rather than +this module reading a single global credential, since every user acts as +themselves now. """ from __future__ import annotations @@ -11,15 +13,11 @@ import requests from .config import Config -def _auth() -> tuple[str, str]: - return (Config.NC_USERNAME, Config.NC_APP_PASSWORD) +def _webdav_root(username: str) -> str: + return f"{Config.NC_BASE_URL}/remote.php/dav/files/{username}" -def _webdav_root() -> str: - return f"{Config.NC_BASE_URL}/remote.php/dav/files/{Config.NC_USERNAME}" - - -def ensure_upload_folder() -> None: +def ensure_upload_folder(username: str, app_password: str) -> None: """MKCOL each segment of the configured upload folder path if missing. NC_UPLOAD_FOLDER may be nested (e.g. "Documents/Cospend/Assets"); MKCOL @@ -32,35 +30,42 @@ def ensure_upload_folder() -> None: partial = "" for segment in segments: partial = f"{partial}/{segment}" if partial else segment - url = f"{_webdav_root()}/{partial}" - resp = requests.request("MKCOL", url, auth=_auth(), timeout=15) + url = f"{_webdav_root(username)}/{partial}" + resp = requests.request("MKCOL", url, auth=(username, app_password), timeout=15) # 201 = created, 405 = already exists. Anything else is a real problem. if resp.status_code not in (201, 405): resp.raise_for_status() -def upload_file(filename: str, content: bytes, content_type: str = "image/jpeg") -> str: - """Uploads `content` to / via WebDAV PUT. +def upload_file( + username: str, app_password: str, filename: str, content: bytes, content_type: str = "image/jpeg" +) -> str: + """Uploads `content` to / via WebDAV PUT, into + the given user's own NC files. Returns the server-relative path (e.g. "wgBill/receipt-123.jpg"), which is what the Share API expects as `path`. """ - ensure_upload_folder() + ensure_upload_folder(username, app_password) rel_path = f"{Config.NC_UPLOAD_FOLDER}/{filename}" if Config.NC_UPLOAD_FOLDER else filename - url = f"{_webdav_root()}/{rel_path}" + url = f"{_webdav_root(username)}/{rel_path}" resp = requests.put( - url, data=content, auth=_auth(), headers={"Content-Type": content_type}, timeout=30 + url, + data=content, + auth=(username, app_password), + headers={"Content-Type": content_type}, + timeout=30, ) resp.raise_for_status() return rel_path -def create_public_share(rel_path: str) -> str: +def create_public_share(username: str, app_password: str, rel_path: str) -> str: """Creates a public read-only link share for `rel_path`. Returns the share URL.""" url = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares" resp = requests.post( url, - auth=_auth(), + auth=(username, app_password), headers={"OCS-APIRequest": "true"}, data={ "path": f"/{rel_path}", diff --git a/backend/app/routes.py b/backend/app/routes.py index 6a63cbe..aa66822 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -6,13 +6,24 @@ import uuid from datetime import date as date_cls from datetime import datetime -from flask import Blueprint, jsonify, request +from flask import Blueprint, g, jsonify, request -from . import cospend_client, highlight, llm_client, nc_client +from . import cospend_client, llm_client, nc_client +from .auth import enforce_login from .config import Config from .db import get_conn bp = Blueprint("api", __name__) +# Every route on this blueprint requires a valid session by default - a +# route added here later is protected automatically, not only if someone +# remembers to decorate it. (auth_routes.py's /auth/* blueprint is +# deliberately separate and unguarded - it's the login mechanism itself.) +bp.before_request(enforce_login) + + +@bp.get("/me") +def me(): + return jsonify(nc_user_id=g.nc_user_id) # ---------------------------------------------------------------- receipts @@ -43,9 +54,9 @@ def create_receipt(): with get_conn() as conn: conn.execute( - """INSERT INTO receipts (id, image_path, items_json, store_name, receipt_date) - VALUES (?, ?, ?, ?, ?)""", - (receipt_id, image_path, json.dumps(items), store_name, receipt_date), + """INSERT INTO receipts (id, owner_nc_user_id, image_path, items_json, store_name, receipt_date) + VALUES (?, ?, ?, ?, ?, ?)""", + (receipt_id, g.nc_user_id, image_path, json.dumps(items), store_name, receipt_date), ) return jsonify(id=receipt_id, items=items, store_name=store_name, date=receipt_date), 201 @@ -53,7 +64,7 @@ def create_receipt(): @bp.get("/receipts/") def get_receipt(receipt_id: str): - receipt = _load_receipt(receipt_id) + receipt = _load_receipt(receipt_id, g.nc_user_id) if receipt is None: return jsonify(error="not found"), 404 return jsonify(receipt) @@ -69,8 +80,8 @@ def update_items(receipt_id: str): with get_conn() as conn: cur = conn.execute( - "UPDATE receipts SET items_json = ? WHERE id = ?", - (json.dumps(items), receipt_id), + "UPDATE receipts SET items_json = ? WHERE id = ? AND owner_nc_user_id = ?", + (json.dumps(items), receipt_id, g.nc_user_id), ) if cur.rowcount == 0: return jsonify(error="not found"), 404 @@ -95,19 +106,24 @@ def update_meta(receipt_id: str): if not fields: return jsonify(error="nothing to update"), 400 - values.append(receipt_id) + values.extend([receipt_id, g.nc_user_id]) with get_conn() as conn: - cur = conn.execute(f"UPDATE receipts SET {', '.join(fields)} WHERE id = ?", values) + cur = conn.execute( + f"UPDATE receipts SET {', '.join(fields)} WHERE id = ? AND owner_nc_user_id = ?", values + ) if cur.rowcount == 0: return jsonify(error="not found"), 404 - receipt = _load_receipt(receipt_id) + receipt = _load_receipt(receipt_id, g.nc_user_id) return jsonify(receipt) -def _load_receipt(receipt_id: str) -> dict | None: +def _load_receipt(receipt_id: str, owner_nc_user_id: str) -> dict | None: with get_conn() as conn: - row = conn.execute("SELECT * FROM receipts WHERE id = ?", (receipt_id,)).fetchone() + row = conn.execute( + "SELECT * FROM receipts WHERE id = ? AND owner_nc_user_id = ?", + (receipt_id, owner_nc_user_id), + ).fetchone() if row is None: return None return { @@ -125,7 +141,7 @@ def _load_receipt(receipt_id: str) -> dict | None: @bp.get("/cospend/projects") def cospend_projects(): try: - projects = cospend_client.get_projects() + projects = cospend_client.get_projects(g.nc_user_id, g.nc_app_password) except Exception as exc: # noqa: BLE001 return jsonify(error=f"cospend request failed: {exc}"), 502 return jsonify(projects=projects, default_project_id=Config.COSPEND_DEFAULT_PROJECT_ID or None) @@ -134,7 +150,7 @@ def cospend_projects(): @bp.get("/cospend/projects//members") def cospend_members(project_id: str): try: - members = cospend_client.get_members(project_id) + members = cospend_client.get_members(g.nc_user_id, g.nc_app_password, project_id) except Exception as exc: # noqa: BLE001 return jsonify(error=f"cospend request failed: {exc}"), 502 return jsonify(members) @@ -144,13 +160,16 @@ def cospend_members(project_id: str): @bp.post("/receipts//groups") def create_group(receipt_id: str): - """Body: {name, cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}""" - receipt = _load_receipt(receipt_id) + """Body: {cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]} + + No user-facing "name" - the group is fully described by the project + + who's in the split, which is already what payer/member_ids capture. + """ + receipt = _load_receipt(receipt_id, g.nc_user_id) if receipt is None: return jsonify(error="not found"), 404 body = request.get_json(force=True) or {} - name = body.get("name") or "WG" project_id = body.get("cospend_project_id") payer_member_id = body.get("payer_member_id") member_ids = body.get("member_ids") or [] @@ -168,13 +187,12 @@ def create_group(receipt_id: str): with get_conn() as conn: conn.execute( """INSERT INTO groups - (id, receipt_id, name, cospend_project_id, payer_member_id, + (id, receipt_id, cospend_project_id, payer_member_id, member_ids_json, item_ids_json) - VALUES (?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?)""", ( group_id, receipt_id, - name, project_id, payer_member_id, json.dumps(member_ids), @@ -188,6 +206,10 @@ def create_group(receipt_id: str): @bp.get("/receipts//groups") def list_groups(receipt_id: str): + # Ownership check on the parent receipt is enough - a group can't exist + # without a receipt row, and receipts are already scoped per-owner. + if _load_receipt(receipt_id, g.nc_user_id) is None: + return jsonify(error="not found"), 404 with get_conn() as conn: rows = conn.execute( "SELECT * FROM groups WHERE receipt_id = ? ORDER BY created_at", (receipt_id,) @@ -199,7 +221,6 @@ def _group_dict(row) -> dict: return { "id": row["id"], "receipt_id": row["receipt_id"], - "name": row["name"], "cospend_project_id": row["cospend_project_id"], "payer_member_id": row["payer_member_id"], "member_ids": json.loads(row["member_ids_json"]), @@ -213,8 +234,8 @@ def _group_dict(row) -> dict: @bp.post("/receipts//groups//submit") def submit_group(receipt_id: str, group_id: str): - """Highlights selected items, uploads to NC, shares, creates the Cospend bill.""" - receipt = _load_receipt(receipt_id) + """Uploads the receipt photo to NC, shares it, creates the Cospend bill.""" + receipt = _load_receipt(receipt_id, g.nc_user_id) if receipt is None: return jsonify(error="receipt not found"), 404 @@ -232,33 +253,61 @@ def submit_group(receipt_id: str, group_id: str): return jsonify(error="no matching items on this receipt"), 400 total = round(sum(item["price"] for item in selected), 2) - bboxes = [item["bbox"] for item in selected if item.get("bbox")] - with open(_receipt_image_path(receipt_id), "rb") as f: + with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f: original_bytes = f.read() try: - highlighted = highlight.highlight_items(original_bytes, bboxes) + # Members list is fetched here (not stored at group-creation time) so + # the label always reflects real names - also gives us the payer's + # name for a slightly more readable title. + members = cospend_client.get_members( + g.nc_user_id, g.nc_app_password, group["cospend_project_id"] + ) + names_by_id = {str(m["id"]): m["name"] for m in members} + # member ids come back from Cospend as JSON numbers, not strings, and + # may have round-tripped through JS (which doesn't distinguish) - str() + # everything here so a missed match can't leave a raw int in the list + # `join` chokes on. + ower_names = [names_by_id.get(str(m), str(m)) for m in group["member_ids"]] + members_label = " + ".join(ower_names) if ower_names else "split" + # No highlighting - LLM-provided bounding boxes were unreliable + # enough on real receipts to not be worth it. Just upload the + # original photo, into the submitting user's own NC files. # Timestamp for human sorting/browsing in NC, group id suffix so two # submits in the same second (or a retry) never collide/overwrite. - safe_name = re.sub(r"[^A-Za-z0-9_-]+", "-", group["name"]).strip("-") or "group" - filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_name}_{group_id[:8]}.jpg" - rel_path = nc_client.upload_file(filename, highlighted) - share_url = nc_client.create_public_share(rel_path) + safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", members_label).strip("-") or "group" + filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_label}_{group_id[:8]}.jpg" + rel_path = nc_client.upload_file( + g.nc_user_id, g.nc_app_password, filename, original_bytes + ) + share_url = nc_client.create_public_share(g.nc_user_id, g.nc_app_password, rel_path) item_lines = "\n".join(f"- {i['label']}: {i['price']}" for i in selected) - # Descriptive title: store name when we have one, else just the - # group name - either way followed by the share link, since + # Descriptive title: store name when we have one, else fall back to + # who's splitting it - either way followed by the share link, since # Cospend's UI only recognizes a bill as having an attachment when # the link is in `what` (the title); a link in `comment` is just # plain text and isn't picked up. + # Only mention who it's split with when there's more than one bill + # off this receipt to tell apart - with a single bill it's just + # noise (the split is already visible in the bill itself). + with get_conn() as conn: + (bill_count,) = conn.execute( + "SELECT COUNT(*) FROM groups WHERE receipt_id = ?", (receipt_id,) + ).fetchone() store_name = receipt.get("store_name") - label = f"{store_name} ({group['name']})" if store_name else group["name"] + if bill_count > 1: + label = f"{store_name} ({members_label})" if store_name else members_label + else: + label = store_name or "receipt" bill_date = receipt.get("date") or date_cls.today().isoformat() bill_id = cospend_client.create_bill( + g.nc_user_id, + g.nc_app_password, project_id=group["cospend_project_id"], what=f"{label} {share_url}", amount=total, @@ -285,7 +334,10 @@ def submit_group(receipt_id: str, group_id: str): return jsonify(share_url=share_url, bill_id=bill_id, amount=total) -def _receipt_image_path(receipt_id: str) -> str: +def _receipt_image_path(receipt_id: str, owner_nc_user_id: str) -> str: with get_conn() as conn: - row = conn.execute("SELECT image_path FROM receipts WHERE id = ?", (receipt_id,)).fetchone() + row = conn.execute( + "SELECT image_path FROM receipts WHERE id = ? AND owner_nc_user_id = ?", + (receipt_id, owner_nc_user_id), + ).fetchone() return row["image_path"] diff --git a/backend/app/schema.sql b/backend/app/schema.sql index f14fcb8..a961b0b 100644 --- a/backend/app/schema.sql +++ b/backend/app/schema.sql @@ -1,5 +1,25 @@ +CREATE TABLE IF NOT EXISTS users ( + nc_user_id TEXT PRIMARY KEY, + -- Fernet-encrypted NC app password obtained via Login Flow v2. Never + -- returned by any API response. + nc_app_password_encrypted BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + -- SHA-256 hex digest of the actual session cookie value - never the raw + -- token, so a DB read alone can't yield a usable session (same + -- principle as password hashing). + session_id_hash TEXT PRIMARY KEY, + nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS receipts ( id TEXT PRIMARY KEY, + owner_nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE, image_path TEXT NOT NULL, image_width INTEGER, image_height INTEGER, @@ -14,7 +34,6 @@ CREATE TABLE IF NOT EXISTS receipts ( CREATE TABLE IF NOT EXISTS groups ( id TEXT PRIMARY KEY, receipt_id TEXT NOT NULL REFERENCES receipts(id) ON DELETE CASCADE, - name TEXT NOT NULL, cospend_project_id TEXT NOT NULL, payer_member_id TEXT NOT NULL, member_ids_json TEXT NOT NULL, -- json list of cospend member ids (owers) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0eda55d..3edccfa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,4 @@ Flask-Cors==4.0.1 python-dotenv==1.0.1 requests==2.32.3 Pillow==10.4.0 +cryptography==43.0.1 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6aaebfe..2e57f31 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,14 +10,17 @@ "dependencies": { "react": "^19.2.8", "react-dom": "^19.2.8", + "react-icons": "^5.7.0", "vite-plugin-pwa": "^1.3.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", "oxlint": "^1.79.0", + "tailwindcss": "^4.3.3", "typescript": "~6.0.2", "vite": "^8.2.2" } @@ -2587,6 +2590,539 @@ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -3184,6 +3720,20 @@ "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4219,6 +4769,16 @@ "node": ">=10" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4866,6 +5426,15 @@ "react": "^19.2.8" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5474,6 +6043,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 72d462b..3b13b44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,14 +12,17 @@ "dependencies": { "react": "^19.2.8", "react-dom": "^19.2.8", + "react-icons": "^5.7.0", "vite-plugin-pwa": "^1.3.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", "oxlint": "^1.79.0", + "tailwindcss": "^4.3.3", "typescript": "~6.0.2", "vite": "^8.2.2" } diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..b5449e8 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/pwa-192x192.png b/frontend/public/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..110181e6f2b70c11284d8691408aea2f6f0832a0 GIT binary patch literal 4781 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 XIqIMg@k5Z(e*$zg^))`J*+2g;MH5du literal 0 HcmV?d00001 diff --git a/frontend/public/pwa-512x512.png b/frontend/public/pwa-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..8c2e4d4f989e78a7f4fd327f09209b019db963ea GIT binary patch literal 13684 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 name: 'capture' }) return ( -

- {stage.name === 'capture' && ( - - setStage({ name: 'review', receiptId, items, storeName, date: date || today() }) - } - /> - )} + + {({ ncUserId, logout }) => ( +
+
+ {ncUserId} + +
- {stage.name === 'review' && ( - setStage({ name: 'group', receiptId: stage.receiptId, items })} - /> - )} + {stage.name === 'capture' && ( + + setStage({ name: 'group', receiptId, items, storeName, date: date || today() }) + } + /> + )} - {stage.name === 'group' && ( - setStage({ name: 'summary', groups })} - /> - )} + {stage.name === 'group' && ( + setStage({ name: 'summary', groups })} + /> + )} - {stage.name === 'summary' && ( - setStage({ name: 'capture' })} /> + {stage.name === 'summary' && ( + setStage({ name: 'capture' })} /> + )} +
)} -
+ ) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f23719a..c97bbd9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,11 +2,19 @@ import type { CospendProject, Group, Item, Member, Receipt, SubmitResult } from const BASE = import.meta.env.VITE_API_BASE_URL ?? '' +export class UnauthorizedError extends Error {} + async function req(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE}/api${path}`, { + const res = await fetch(`${BASE}${path}`, { ...init, + // Auth is a cookie now (see auth.py) - needed even same-origin-via-proxy + // in dev, and required for a cross-origin prod deployment. + credentials: 'include', headers: init?.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init?.headers }, }) + if (res.status === 401) { + throw new UnauthorizedError('not logged in') + } if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error || `${res.status} ${res.statusText}`) @@ -15,52 +23,75 @@ async function req(path: string, init?: RequestInit): Promise { } export const api = { + getMe(): Promise<{ nc_user_id: string }> { + return req('/api/me') + }, + + authStart(): Promise<{ flow_id: string; login_url: string }> { + return req('/auth/login/start', { method: 'POST' }) + }, + + authPoll( + flowId: string, + ): Promise<{ status: 'pending' | 'expired' | 'done'; nc_user_id?: string }> { + return req(`/auth/login/poll?flow_id=${encodeURIComponent(flowId)}`) + }, + + logout(): Promise<{ status: string }> { + return req('/auth/logout', { method: 'POST' }) + }, + uploadReceipt( file: File | Blob, ): Promise<{ id: string; items: Item[]; store_name: string | null; date: string }> { const form = new FormData() form.append('image', file, 'receipt.jpg') - return req('/receipts', { method: 'POST', body: form }) + return req('/api/receipts', { method: 'POST', body: form }) }, getReceipt(id: string): Promise { - return req(`/receipts/${id}`) + return req(`/api/receipts/${id}`) }, updateItems(id: string, items: Item[]): Promise { - return req(`/receipts/${id}/items`, { method: 'PATCH', body: JSON.stringify({ items }) }) + return req(`/api/receipts/${id}/items`, { method: 'PATCH', body: JSON.stringify({ items }) }) }, updateMeta(id: string, meta: { store_name?: string; date?: string }): Promise { - return req(`/receipts/${id}/meta`, { method: 'PATCH', body: JSON.stringify(meta) }) + return req(`/api/receipts/${id}/meta`, { method: 'PATCH', body: JSON.stringify(meta) }) }, getProjects(): Promise<{ projects: CospendProject[]; default_project_id: string | null }> { - return req('/cospend/projects') + return req('/api/cospend/projects') }, - getMembers(projectId: string): Promise { - return req(`/cospend/projects/${projectId}/members`) + async getMembers(projectId: string): Promise { + // Cospend returns member ids as JSON numbers; normalize to strings here + // so every consumer downstream can rely on the Member/Group types' + // `string` id fields actually being strings, not silently numbers. + const raw = await req>( + `/api/cospend/projects/${projectId}/members`, + ) + return raw.map((m) => ({ id: String(m.id), name: m.name })) }, createGroup( receiptId: string, group: { - name: string cospend_project_id: string payer_member_id: string member_ids: string[] item_ids: string[] }, ): Promise<{ id: string }> { - return req(`/receipts/${receiptId}/groups`, { method: 'POST', body: JSON.stringify(group) }) + return req(`/api/receipts/${receiptId}/groups`, { method: 'POST', body: JSON.stringify(group) }) }, submitGroup(receiptId: string, groupId: string): Promise { - return req(`/receipts/${receiptId}/groups/${groupId}/submit`, { method: 'POST' }) + return req(`/api/receipts/${receiptId}/groups/${groupId}/submit`, { method: 'POST' }) }, listGroups(receiptId: string): Promise { - return req(`/receipts/${receiptId}/groups`) + return req(`/api/receipts/${receiptId}/groups`) }, } diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/src/components/CaptureStep.tsx b/frontend/src/components/CaptureStep.tsx index 058fa59..36a4460 100644 --- a/frontend/src/components/CaptureStep.tsx +++ b/frontend/src/components/CaptureStep.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react' +import { CiReceipt } from 'react-icons/ci' import { api } from '../api' import type { Item } from '../types' @@ -10,6 +11,7 @@ export function CaptureStep({ onExtracted }: Props) { const inputRef = useRef(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [dragging, setDragging] = useState(false) async function handleFile(file: File | undefined) { if (!file) return @@ -25,10 +27,33 @@ export function CaptureStep({ onExtracted }: Props) { } } + function onDrop(e: React.DragEvent) { + e.preventDefault() + setDragging(false) + const file = [...e.dataTransfer.files].find((f) => f.type.startsWith('image/')) + handleFile(file) + } + return ( -
-

wgBill

-

Take a photo of the receipt to get started.

+
{ + e.preventDefault() + setDragging(true) + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + > + +

wgBill

+

+ Take a photo of the receipt to get started. +

+ handleFile(e.target.files?.[0])} /> - - {error &&

{error}

} + +

+ or drop an image anywhere on this page +

+ + {error &&

{error}

}
) } diff --git a/frontend/src/components/GroupStep.tsx b/frontend/src/components/GroupStep.tsx index 141846b..5eee61e 100644 --- a/frontend/src/components/GroupStep.tsx +++ b/frontend/src/components/GroupStep.tsx @@ -1,31 +1,65 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { CiReceipt } from 'react-icons/ci' import { api } from '../api' import type { CospendProject, Group, Item, Member } from '../types' +// 's displayed format follows the browser/OS locale, not +// anything we control from the page - so for a fixed DD.MM.YYYY display we +// use a plain text field instead, converting to/from the ISO date the +// backend expects. +function isoToEu(iso: string): string { + const [y, m, d] = iso.split('-') + return y && m && d ? `${d}.${m}.${y}` : '' +} +function euToIso(eu: string): string | null { + const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(eu.trim()) + if (!match) return null + const [, d, m, y] = match + return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}` +} + interface Props { receiptId: string - items: Item[] + initialItems: Item[] + initialStoreName: string | null + initialDate: string onAllDone: (groups: Group[]) => void } -export function GroupStep({ receiptId, items, onAllDone }: Props) { +/** One computed bill: a distinct set of owers, and the items assigned to it. */ +interface Partition { + memberIds: string[] + items: Item[] + total: number +} + +export function GroupStep({ + receiptId, + initialItems, + initialStoreName, + initialDate, + onAllDone, +}: Props) { + // --- receipt-level fields (was the separate "review" step) --- + const [items, setItems] = useState(initialItems) + const [storeName, setStoreName] = useState(initialStoreName ?? '') + const [date, setDate] = useState(initialDate) + const [dateText, setDateText] = useState(isoToEu(initialDate)) + + // --- cospend data --- const [projects, setProjects] = useState([]) const [projectId, setProjectId] = useState(null) const [members, setMembers] = useState([]) const [loadError, setLoadError] = useState(null) - const [remainingIds, setRemainingIds] = useState>(new Set(items.map((i) => i.id))) - const [groups, setGroups] = useState([]) - - // current draft - const [name, setName] = useState('WG') const [payerId, setPayerId] = useState(null) - const [selectedMemberIds, setSelectedMemberIds] = useState>(new Set()) - const [selectedItemIds, setSelectedItemIds] = useState>(new Set()) + // itemId -> set of member ids splitting that item. Missing/empty = excluded. + const [itemMembers, setItemMembers] = useState>>({}) + const [customizing, setCustomizing] = useState(null) + const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) - // load Cospend projects once, pick the configured default (or the only one) useEffect(() => { api .getProjects() @@ -37,78 +71,124 @@ export function GroupStep({ receiptId, items, onAllDone }: Props) { .catch((e) => setLoadError(e instanceof Error ? e.message : String(e))) }, []) - // load members whenever the selected project changes useEffect(() => { if (!projectId) return - api - .getMembers(projectId) - .then((m) => { + Promise.all([api.getMembers(projectId), api.getMe()]) + .then(([m, { nc_user_id }]) => { setMembers(m) if (m.length > 0) { - setPayerId(m[0].id) - setSelectedMemberIds(new Set(m.map((mm) => mm.id))) + setPayerId(fuzzyMatchMember(m, nc_user_id)?.id ?? m[0].id) } }) .catch((e) => setLoadError(e instanceof Error ? e.message : String(e))) }, [projectId]) - function toggleItem(id: string) { - setSelectedItemIds((prev) => { - const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) + const allMemberIds = useMemo(() => members.map((m) => m.id), [members]) + + function updateItem(id: string, patch: Partial) { + setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it))) + } + + function removeItem(id: string) { + setItems((prev) => prev.filter((it) => it.id !== id)) + setItemMembers((prev) => { + const next = { ...prev } + delete next[id] return next }) } - function toggleMember(id: string) { - setSelectedMemberIds((prev) => { - const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) + function addItem() { + const id = crypto.randomUUID() + setItems((prev) => [...prev, { id, label: '', price: 0 }]) + } + + function isIncluded(itemId: string) { + return (itemMembers[itemId]?.size ?? 0) > 0 + } + + /** The primary, one-click action: off -> everyone, on -> off. */ + function toggleIncluded(itemId: string) { + setItemMembers((prev) => { + const next = { ...prev } + if (isIncluded(itemId)) { + delete next[itemId] + } else { + next[itemId] = new Set(allMemberIds) + } return next }) } - const remainingItems = items.filter((i) => remainingIds.has(i.id)) - const groupTotal = items - .filter((i) => selectedItemIds.has(i.id)) - .reduce((sum, i) => sum + i.price, 0) + function toggleItemMember(itemId: string, memberId: string) { + setItemMembers((prev) => { + const current = new Set(prev[itemId] ?? []) + current.has(memberId) ? current.delete(memberId) : current.add(memberId) + const next = { ...prev, [itemId]: current } + if (current.size === 0) delete next[itemId] + return next + }) + } - async function submitCurrentGroup() { - if (!projectId) { - setError('pick a Cospend project') + // Group included items by identical ower-set - each distinct set becomes + // its own bill, since a Cospend bill can only have one split. + const partitions: Partition[] = useMemo(() => { + const byKey = new Map() + for (const item of items) { + const memberSet = itemMembers[item.id] + if (!memberSet || memberSet.size === 0) continue + const memberIds = [...memberSet].sort() + const key = memberIds.join(',') + const existing = byKey.get(key) + if (existing) { + existing.items.push(item) + existing.total += item.price + } else { + byKey.set(key, { memberIds, items: [item], total: item.price }) + } + } + return [...byKey.values()] + }, [items, itemMembers]) + + const excludedCount = items.length - partitions.reduce((n, p) => n + p.items.length, 0) + + function memberName(id: string) { + return members.find((m) => m.id === id)?.name ?? id + } + + async function submitAll() { + if (!projectId || !payerId) { + setError('pick a Cospend project and who paid') return } - if (!payerId || selectedMemberIds.size === 0 || selectedItemIds.size === 0) { - setError('pick a payer, at least one member, and at least one item') + if (partitions.length === 0) { + setError('include at least one item') return } setSubmitting(true) setError(null) try { - const { id: groupId } = await api.createGroup(receiptId, { - name, - cospend_project_id: projectId, - payer_member_id: payerId, - member_ids: [...selectedMemberIds], - item_ids: [...selectedItemIds], - }) - const result = await api.submitGroup(receiptId, groupId) - const allGroups = await api.listGroups(receiptId) - setGroups(allGroups) + await api.updateItems(receiptId, items) + await api.updateMeta(receiptId, { store_name: storeName, date }) - setRemainingIds((prev) => { - const next = new Set(prev) - selectedItemIds.forEach((id) => next.delete(id)) - return next - }) - const wasLastGroup = remainingItems.length === selectedItemIds.size - setSelectedItemIds(new Set()) - setName('WG') - - if (wasLastGroup) { - onAllDone(allGroups) + // Create every group first, then submit - so by the time any of them + // submits, the backend can see the receipt's real total bill count + // (and only mention who's splitting it in the title when there's more + // than one bill to tell apart). + const groupIds: string[] = [] + for (const partition of partitions) { + const { id: groupId } = await api.createGroup(receiptId, { + cospend_project_id: projectId, + payer_member_id: payerId, + member_ids: partition.memberIds, + item_ids: partition.items.map((i) => i.id), + }) + groupIds.push(groupId) } - void result + for (const groupId of groupIds) { + await api.submitGroup(receiptId, groupId) + } + onAllDone(await api.listGroups(receiptId)) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -117,108 +197,220 @@ export function GroupStep({ receiptId, items, onAllDone }: Props) { } if (loadError) { - return ( -
-

Couldn't load Cospend data: {loadError}

-
- ) + return

Couldn't load Cospend data: {loadError}

} return ( -
-

Who's splitting this?

-

- Select the items for one splitting arrangement, pick who's in on it, submit. Repeat for - any items that need a different group (e.g. only your snack). -

+
+
+ +

Split the receipt

+
- - - {groups.length > 0 && ( -
- {groups.map((g) => ( -

- ✓ {g.name} bill created —{' '} - - receipt - -

- ))} -
- )} - - setName(e.target.value)} - placeholder="group name (e.g. WG, just me + Alex)" - /> - -

Items ({remainingItems.length} left)

-
    - {remainingItems.map((item) => ( -
  • toggleItem(item.id)} +
    + + setStoreName(e.target.value)} + className="input" + /> + + + { + setDateText(e.target.value) + const iso = euToIso(e.target.value) + if (iso) setDate(iso) + }} + className="input" + /> + + + toggleMember(m.id)} - /> - {m.name} - -
  • - ))} -
- - + {projects.map((p) => ( + + ))} + + + + + +
-

Group total: {groupTotal.toFixed(2)}

- {error &&

{error}

} +
    + {items.map((item) => { + const included = isIncluded(item.id) + const selectedMembers = itemMembers[item.id] ?? new Set() + return ( +
  • +
    + - + + +
    + + {customizing === item.id && ( +
    + {members.map((m) => ( + + ))} +
    + )} +
  • + ) + })} +
+ + - {remainingItems.length === 0 && groups.length > 0 && ( - - )} +
+

+ {partitions.length} bill{partitions.length === 1 ? '' : 's'} to create + {excludedCount > 0 && ( + · {excludedCount} excluded + )} +

+
    + {partitions.map((p) => ( +
  • + + {p.memberIds.map(memberName).join(' + ')}{' '} + ({p.items.length} item(s)) + + {p.total.toFixed(2)} +
  • + ))} +
+
+ + {error &&

{error}

} + +
) } + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ) +} + +/** Best-effort match of the logged-in NC user against a Cospend member + * name, so we can preselect them as the default payer without hardcoding a + * project-specific member id anywhere. Case-insensitive exact match first, + * then substring containment either direction; null if nothing looks close + * enough (caller falls back to the first member). */ +function fuzzyMatchMember(members: Member[], ncUsername: string): Member | null { + const needle = ncUsername.trim().toLowerCase() + if (!needle) return null + + const exact = members.find((m) => m.name.trim().toLowerCase() === needle) + if (exact) return exact + + const partial = members.find((m) => { + const name = m.name.trim().toLowerCase() + return name.includes(needle) || needle.includes(name) + }) + return partial ?? null +} + +function memberInitials(ids: Set, members: Member[]): string { + if (ids.size === members.length && members.length > 0) return 'everyone' + return [...ids].map((id) => members.find((m) => m.id === id)?.name.slice(0, 3) ?? '?').join(',') +} diff --git a/frontend/src/components/LoginGate.tsx b/frontend/src/components/LoginGate.tsx new file mode 100644 index 0000000..2b32d25 --- /dev/null +++ b/frontend/src/components/LoginGate.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from 'react' +import { CiReceipt } from 'react-icons/ci' +import { api, UnauthorizedError } from '../api' + +interface Props { + /** Rendered once a session is confirmed. */ + children: (ctx: { ncUserId: string; logout: () => void }) => React.ReactNode +} + +type Status = + | { name: 'checking' } + | { name: 'loggedOut' } + | { name: 'starting' } + | { name: 'polling'; flowId: string } + | { name: 'error'; message: string } + | { name: 'loggedIn'; ncUserId: string } + +const POLL_INTERVAL_MS = 1500 + +export function LoginGate({ children }: Props) { + const [status, setStatus] = useState({ name: 'checking' }) + const openedWindow = useRef(null) + + useEffect(() => { + api + .getMe() + .then(({ nc_user_id }) => setStatus({ name: 'loggedIn', ncUserId: nc_user_id })) + .catch((e) => { + if (e instanceof UnauthorizedError) setStatus({ name: 'loggedOut' }) + else setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) }) + }) + }, []) + + useEffect(() => { + if (status.name !== 'polling') return + const flowId = status.flowId + const interval = setInterval(async () => { + try { + const result = await api.authPoll(flowId) + if (result.status === 'done' && result.nc_user_id) { + openedWindow.current?.close() + setStatus({ name: 'loggedIn', ncUserId: result.nc_user_id }) + } else if (result.status === 'expired') { + setStatus({ name: 'error', message: 'Login timed out - try again.' }) + } + } catch (e) { + setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) }) + } + }, POLL_INTERVAL_MS) + return () => clearInterval(interval) + }, [status]) + + async function login() { + setStatus({ name: 'starting' }) + try { + const { flow_id, login_url } = await api.authStart() + openedWindow.current = window.open(login_url, '_blank') + setStatus({ name: 'polling', flowId: flow_id }) + } catch (e) { + setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) }) + } + } + + function logout() { + api.logout().finally(() => setStatus({ name: 'loggedOut' })) + } + + if (status.name === 'loggedIn') { + return <>{children({ ncUserId: status.ncUserId, logout })} + } + + return ( +
+ +

wgBill

+ + {status.name === 'checking' && ( +

Checking login…

+ )} + + {(status.name === 'loggedOut' || status.name === 'starting') && ( + <> +

+ Log in with your Nextcloud account to get started. +

+ + + )} + + {status.name === 'polling' && ( +

+ Complete the login in the tab that just opened, then come back here. +

+ )} + + {status.name === 'error' && ( + <> +

{status.message}

+ + + )} +
+ ) +} diff --git a/frontend/src/components/ReviewStep.tsx b/frontend/src/components/ReviewStep.tsx deleted file mode 100644 index 0e6a9d3..0000000 --- a/frontend/src/components/ReviewStep.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState } from 'react' -import { api } from '../api' -import type { Item } from '../types' - -interface Props { - receiptId: string - initialItems: Item[] - initialStoreName: string | null - initialDate: string - onConfirmed: (items: Item[], storeName: string, date: string) => void -} - -export function ReviewStep({ - receiptId, - initialItems, - initialStoreName, - initialDate, - onConfirmed, -}: Props) { - const [items, setItems] = useState(initialItems) - const [storeName, setStoreName] = useState(initialStoreName ?? '') - const [date, setDate] = useState(initialDate) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) - - function updateItem(id: string, patch: Partial) { - setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it))) - } - - function removeItem(id: string) { - setItems((prev) => prev.filter((it) => it.id !== id)) - } - - function addItem() { - setItems((prev) => [ - ...prev, - { id: crypto.randomUUID(), label: '', price: 0, bbox: null }, - ]) - } - - async function confirm() { - setSaving(true) - setError(null) - try { - await api.updateItems(receiptId, items) - await api.updateMeta(receiptId, { store_name: storeName, date }) - onConfirmed(items, storeName, date) - } catch (e) { - setError(e instanceof Error ? e.message : String(e)) - } finally { - setSaving(false) - } - } - - const total = items.reduce((sum, it) => sum + (Number(it.price) || 0), 0) - - return ( -
-

Check the extracted items

-

Fix anything the receipt reader got wrong before splitting.

- -
- - -
- -
    - {items.map((item) => ( -
  • - updateItem(item.id, { label: e.target.value })} - /> - updateItem(item.id, { price: Number(e.target.value) })} - /> - -
  • - ))} -
- -

Total: {total.toFixed(2)}

- {error &&

{error}

} - -
- ) -} diff --git a/frontend/src/components/SummaryStep.tsx b/frontend/src/components/SummaryStep.tsx index 2d8cdec..2791f61 100644 --- a/frontend/src/components/SummaryStep.tsx +++ b/frontend/src/components/SummaryStep.tsx @@ -7,26 +7,37 @@ interface Props { export function SummaryStep({ groups, onRestart }: Props) { return ( -
-

Done 🎉

-
    +
    +

    Done 🎉

    +
      {groups.map((g) => ( -
    • - {g.name} +
    • {g.status === 'submitted' ? ( <> - {' — bill created. '} - - view receipt + + View receipt ) : ( - — failed: {g.error} + failed: {g.error} )}
    • ))}
    - +
    ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb3313..9c1d324 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,111 +1,15 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; +@import 'tailwindcss'; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; +html { color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; } body { - margin: 0; + @apply m-0 min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; +@layer components { + .input { + @apply rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100; } } -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5b58257..4870de8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,10 +1,7 @@ -export type BBox = [number, number, number, number] // x, y, w, h, normalized 0-1 - export interface Item { id: string label: string price: number - bbox: BBox | null } export interface Receipt { @@ -29,7 +26,6 @@ export interface CospendProject { export interface Group { id: string receipt_id: string - name: string cospend_project_id: string payer_member_id: string member_ids: string[] @@ -45,12 +41,3 @@ export interface SubmitResult { amount: number bill_id: number } - -/** A group draft in progress client-side, before it's persisted via POST /groups. */ -export interface GroupDraft { - localId: string - name: string - payerMemberId: string | null - memberIds: string[] - itemIds: string[] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 06ebd05..04a3c31 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { VitePWA } from 'vite-plugin-pwa' @@ -6,6 +7,7 @@ import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ plugins: [ react(), + tailwindcss(), VitePWA({ registerType: 'autoUpdate', manifest: { @@ -25,6 +27,7 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:5000', + '/auth': 'http://localhost:5000', }, }, })