Compare commits
36 Commits
v0.2.0
...
add6e982a4
| Author | SHA1 | Date | |
|---|---|---|---|
| add6e982a4 | |||
| 414d2eb722 | |||
| 7e4bb4d2c8 | |||
| fb913f0617 | |||
| 39ae988101 | |||
| e2d4a595a4 | |||
| 81bcb1c1ef | |||
| 8fc3d9adda | |||
| 35e0f5a422 | |||
| a25504bd3a | |||
| 70280f70bb | |||
| a933672411 | |||
| ca40fd72c3 | |||
| 4402a993d1 | |||
| cb87456575 | |||
| 3d0c7766f0 | |||
| e76b514872 | |||
| 5bb9588f09 | |||
| d895854dcc | |||
| 55a298f05f | |||
| 0aea0f64ac | |||
| 6c80bf0512 | |||
| 9c57b493a7 | |||
| 609499543e | |||
| 768f7c6247 | |||
| 86b635104c | |||
| e158871a9a | |||
| 1ca50e2bc0 | |||
| 74d7056284 | |||
| 0ba91040f1 | |||
| dc83d7bef2 | |||
| 9d4798a124 | |||
| d552d3d3ec | |||
| 42a826cb73 | |||
| 05b43078b9 | |||
| 7c1be58860 |
+17
-4
@@ -1,7 +1,20 @@
|
|||||||
# Cyclone — environment configuration
|
# Cyclone — environment configuration
|
||||||
# Copy this file to `.env.local` and fill in values for your environment.
|
# Copy this file to `.env.local` and fill in values for your environment.
|
||||||
|
|
||||||
# Base URL for the Python (FastAPI) backend that powers the Upload page and
|
# Required on first boot. Cyclone refuses to start without these unless
|
||||||
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
|
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
|
||||||
# the in-memory sample data store and disable real EDI parsing.
|
# Min 12 chars for password.
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
CYCLONE_ADMIN_USERNAME=admin
|
||||||
|
CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars
|
||||||
|
|
||||||
|
# Base URL for the Python (FastAPI) backend. Leave empty for the
|
||||||
|
# Docker deployment (nginx proxies /api/* to backend on compose network).
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Optional. Set to 1 if you're behind an HTTPS reverse proxy and want
|
||||||
|
# the session cookie to include the Secure flag.
|
||||||
|
# CYCLONE_BEHIND_HTTPS=1
|
||||||
|
|
||||||
|
# Optional. Set to 1 to disable auth entirely (DEV ONLY). When set,
|
||||||
|
# the backend auto-grants admin access without checking credentials.
|
||||||
|
# CYCLONE_AUTH_DISABLED=0
|
||||||
@@ -111,6 +111,49 @@ npm run build
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Cyclone ships with username/password authentication and three predefined roles.
|
||||||
|
|
||||||
|
**Roles:**
|
||||||
|
|
||||||
|
| Role | Can read | Can write (upload, parse, reconcile) | Can manage users |
|
||||||
|
| -------- | -------- | ------------------------------------ | ---------------- |
|
||||||
|
| `viewer` | ✅ | ❌ | ❌ |
|
||||||
|
| `user` | ✅ | ✅ | ❌ |
|
||||||
|
| `admin` | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
**Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and
|
||||||
|
`CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the
|
||||||
|
first admin automatically. On subsequent starts these env vars are ignored, so
|
||||||
|
rotating the bootstrap password doesn't affect an already-seeded admin — use the
|
||||||
|
CLI below to reset it. When running via `docker compose`, both vars are
|
||||||
|
required: compose refuses to start with a clear error if either is missing.
|
||||||
|
|
||||||
|
**CLI.** Manage users from the command line:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m cyclone users create alice --role user --password 'hunter2hunter2'
|
||||||
|
python -m cyclone users list
|
||||||
|
python -m cyclone users disable alice
|
||||||
|
python -m cyclone users reset-password alice
|
||||||
|
python -m cyclone users set-role alice --role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Login.** Browse to `http://localhost:5173` (dev) or `http://localhost:8081`
|
||||||
|
(Docker), sign in on the `/login` page, and you'll be redirected to the
|
||||||
|
dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding
|
||||||
|
expiry — every authenticated request refreshes the TTL, so an active user
|
||||||
|
never gets logged out.
|
||||||
|
|
||||||
|
**Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely
|
||||||
|
(the backend auto-grants admin on every request). **NEVER set this in
|
||||||
|
production** — it's a single env-var trip from wide-open to the public
|
||||||
|
internet. The Docker compose file does not honor this flag.
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full
|
||||||
|
design.
|
||||||
|
|
||||||
## Live updates
|
## Live updates
|
||||||
|
|
||||||
The Claims, Remittances, and Activity pages stay current without
|
The Claims, Remittances, and Activity pages stay current without
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ dependencies = [
|
|||||||
"sqlalchemy>=2.0,<3",
|
"sqlalchemy>=2.0,<3",
|
||||||
"pyyaml>=6.0,<7",
|
"pyyaml>=6.0,<7",
|
||||||
"keyring>=25.0,<26",
|
"keyring>=25.0,<26",
|
||||||
|
# backup_service / backup: encryption-at-rest (SP17). Used at module
|
||||||
|
# top-level by cyclone.backup, so it has to be a hard dep — not an
|
||||||
|
# extra — or the test suite fails to collect when the venv is built
|
||||||
|
# from a clean `uv sync`.
|
||||||
|
"cryptography>=49.0,<50",
|
||||||
|
# passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes bcrypt.__about__
|
||||||
|
# which 4.x removed). Pin bcrypt < 4.1.
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"bcrypt<4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -24,6 +33,7 @@ dev = [
|
|||||||
"pytest-cov>=4.1",
|
"pytest-cov>=4.1",
|
||||||
"pytest-asyncio>=0.23,<1",
|
"pytest-asyncio>=0.23,<1",
|
||||||
"httpx>=0.27,<1",
|
"httpx>=0.27,<1",
|
||||||
|
"pytest-randomly>=4.1",
|
||||||
]
|
]
|
||||||
sqlcipher = [
|
sqlcipher = [
|
||||||
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
# Always run first-admin bootstrap before any other entry path.
|
||||||
|
# Must happen before ``serve`` (uvicorn) AND before the Click CLI
|
||||||
|
# dispatch — otherwise `python -m cyclone users create ...` on a
|
||||||
|
# fresh DB would race with the bootstrap's check, and the API
|
||||||
|
# could come up with zero users.
|
||||||
|
from cyclone.auth import bootstrap
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||||
port = os.environ.get("CYCLONE_PORT", "8000")
|
port = os.environ.get("CYCLONE_PORT", "8000")
|
||||||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||||||
|
|||||||
+151
-75
@@ -29,18 +29,34 @@ from contextlib import asynccontextmanager
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from cyclone import __version__, db
|
from cyclone import __version__, db
|
||||||
|
from cyclone.auth.deps import matrix_gate
|
||||||
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
||||||
from sqlalchemy import desc, or_
|
from sqlalchemy import desc, or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_user_id(request: Request) -> int | None:
|
||||||
|
"""Return the acting user's id from ``request.state.user``, or None.
|
||||||
|
|
||||||
|
``get_current_user``/``matrix_gate`` populate ``request.state.user``
|
||||||
|
for both the authenticated path and the AUTH_DISABLED escape hatch.
|
||||||
|
Returns None when the state hasn't been set (e.g. background jobs
|
||||||
|
or unit tests that bypass auth). Used to stamp ``user_id`` onto
|
||||||
|
audit events without crashing the request.
|
||||||
|
"""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
return getattr(user, "id", None)
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
from cyclone.parsers.exceptions import CycloneParseError
|
||||||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
||||||
from cyclone.parsers.models_270 import (
|
from cyclone.parsers.models_270 import (
|
||||||
@@ -268,9 +284,29 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||||||
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(acks.router)
|
app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
app.include_router(ta1_acks.router)
|
app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def _http_exc_handler(request, exc: HTTPException):
|
||||||
|
if isinstance(exc.detail, dict):
|
||||||
|
# Endpoints that raise HTTPException with a dict detail (e.g.
|
||||||
|
# ``{"error": "Not found", "detail": "..."}``) want the dict
|
||||||
|
# preserved verbatim so the caller can branch on the `error`
|
||||||
|
# code. Wrap under `detail` for the standard envelope.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail},
|
||||||
|
headers=exc.headers,
|
||||||
|
)
|
||||||
|
code = exc.detail if isinstance(exc.detail, str) else "error"
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"error": code, "detail": str(exc.detail)},
|
||||||
|
headers=exc.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_payer(name: str) -> PayerConfig:
|
def _resolve_payer(name: str) -> PayerConfig:
|
||||||
@@ -326,7 +362,7 @@ async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSON
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-837")
|
@app.post("/api/parse-837", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_837(
|
async def parse_837(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -512,7 +548,7 @@ def _reconciliation_summary_for_batch(batch_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-835")
|
@app.post("/api/parse-835", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_835_endpoint(
|
async def parse_835_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -663,7 +699,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|||||||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-999")
|
@app.post("/api/parse-999", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_999_endpoint(
|
async def parse_999_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -756,6 +792,7 @@ async def parse_999_endpoint(
|
|||||||
entity_id=cid,
|
entity_id=cid,
|
||||||
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
|
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
|
||||||
actor="999-parser",
|
actor="999-parser",
|
||||||
|
user_id=_actor_user_id(request),
|
||||||
))
|
))
|
||||||
audit_s.commit()
|
audit_s.commit()
|
||||||
|
|
||||||
@@ -789,7 +826,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|||||||
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
|
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-ta1")
|
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_ta1_endpoint(
|
async def parse_ta1_endpoint(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -885,7 +922,7 @@ def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|||||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-277ca")
|
@app.post("/api/parse-277ca", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_277ca_endpoint(
|
async def parse_277ca_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -979,6 +1016,7 @@ async def parse_277ca_endpoint(
|
|||||||
"277ca_id": row.id,
|
"277ca_id": row.id,
|
||||||
},
|
},
|
||||||
actor="277ca-parser",
|
actor="277ca-parser",
|
||||||
|
user_id=_actor_user_id(request),
|
||||||
))
|
))
|
||||||
audit_s.commit()
|
audit_s.commit()
|
||||||
if apply_result.orphans:
|
if apply_result.orphans:
|
||||||
@@ -1004,7 +1042,7 @@ async def parse_277ca_endpoint(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/277ca-acks")
|
@app.get("/api/277ca-acks", dependencies=[Depends(matrix_gate)])
|
||||||
def list_277ca_acks_endpoint(
|
def list_277ca_acks_endpoint(
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -1014,7 +1052,7 @@ def list_277ca_acks_endpoint(
|
|||||||
return {"total": len(rows), "items": items}
|
return {"total": len(rows), "items": items}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/277ca-acks/{ack_id}")
|
@app.get("/api/277ca-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||||||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||||||
row = store.get_277ca_ack(ack_id)
|
row = store.get_277ca_ack(ack_id)
|
||||||
@@ -1078,10 +1116,19 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/inbox/lanes")
|
@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_lanes():
|
def inbox_lanes(request: Request):
|
||||||
"""Return all Inbox lanes in one call."""
|
"""Return all Inbox lanes in one call.
|
||||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
|
||||||
|
Uses ``request.app.state`` rather than the module-level ``app``
|
||||||
|
global so the endpoint is robust against ``importlib.reload`` of
|
||||||
|
this module (some tests do this to mutate the CORS allow-list).
|
||||||
|
After a reload, the module-level ``app`` rebinds to a new
|
||||||
|
FastAPI instance; ``request.app`` always points at the instance
|
||||||
|
that is actually serving the current request, so per-request
|
||||||
|
state stays consistent with the test's TestClient target.
|
||||||
|
"""
|
||||||
|
dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
|
||||||
with db.SessionLocal()() as session:
|
with db.SessionLocal()() as session:
|
||||||
from cyclone.inbox_lanes import compute_lanes
|
from cyclone.inbox_lanes import compute_lanes
|
||||||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||||||
@@ -1096,7 +1143,7 @@ def inbox_lanes():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/candidates/{remit_id}/match")
|
@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_match_candidate(remit_id: str, body: dict):
|
def inbox_match_candidate(remit_id: str, body: dict):
|
||||||
"""Manually link a remit to a claim."""
|
"""Manually link a remit to a claim."""
|
||||||
claim_id = body.get("claim_id")
|
claim_id = body.get("claim_id")
|
||||||
@@ -1125,17 +1172,26 @@ def inbox_match_candidate(remit_id: str, body: dict):
|
|||||||
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/candidates/dismiss")
|
@app.post("/api/inbox/candidates/dismiss", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_dismiss_candidates(body: dict):
|
def inbox_dismiss_candidates(body: dict, request: Request):
|
||||||
"""Add candidate pairs to the session-scoped dismissed set."""
|
"""Add candidate pairs to the session-scoped dismissed set.
|
||||||
|
|
||||||
|
Uses ``request.app.state`` rather than the module-level ``app``
|
||||||
|
global so the endpoint is robust against ``importlib.reload`` of
|
||||||
|
this module (some tests do this to mutate the CORS allow-list).
|
||||||
|
After a reload, the module-level ``app`` rebinds to a new
|
||||||
|
FastAPI instance; ``request.app`` always points at the instance
|
||||||
|
that is actually serving the current request, so the test's
|
||||||
|
TestClient target is the one whose state we mutate.
|
||||||
|
"""
|
||||||
pairs = body.get("pairs") or []
|
pairs = body.get("pairs") or []
|
||||||
if not hasattr(app.state, "dismissed_pairs"):
|
if not hasattr(request.app.state, "dismissed_pairs"):
|
||||||
app.state.dismissed_pairs = set()
|
request.app.state.dismissed_pairs = set()
|
||||||
for p in pairs:
|
for p in pairs:
|
||||||
cid = p.get("claim_id")
|
cid = p.get("claim_id")
|
||||||
rid = p.get("remit_id")
|
rid = p.get("remit_id")
|
||||||
if cid and rid:
|
if cid and rid:
|
||||||
app.state.dismissed_pairs.add(frozenset({cid, rid}))
|
request.app.state.dismissed_pairs.add(frozenset({cid, rid}))
|
||||||
return {"ok": True, "dismissed_count": len(pairs)}
|
return {"ok": True, "dismissed_count": len(pairs)}
|
||||||
|
|
||||||
|
|
||||||
@@ -1151,7 +1207,7 @@ def inbox_dismiss_candidates(body: dict):
|
|||||||
# (the timestamp is not bumped). Returns the count actually transitioned
|
# (the timestamp is not bumped). Returns the count actually transitioned
|
||||||
# so the UI can show "3 of 5 were already acknowledged".
|
# so the UI can show "3 of 5 were already acknowledged".
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@app.post("/api/inbox/payer-rejected/acknowledge")
|
@app.post("/api/inbox/payer-rejected/acknowledge", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_acknowledge_payer_rejected(body: dict):
|
def inbox_acknowledge_payer_rejected(body: dict):
|
||||||
"""Mark Payer-Rejected claims as acknowledged by the operator."""
|
"""Mark Payer-Rejected claims as acknowledged by the operator."""
|
||||||
claim_ids = body.get("claim_ids") or []
|
claim_ids = body.get("claim_ids") or []
|
||||||
@@ -1211,7 +1267,7 @@ def inbox_acknowledge_payer_rejected(body: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/rejected/resubmit")
|
@app.post("/api/inbox/rejected/resubmit", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_resubmit_rejected(
|
def inbox_resubmit_rejected(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: dict,
|
body: dict,
|
||||||
@@ -1305,7 +1361,7 @@ def inbox_resubmit_rejected(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/batches/{batch_id}/export-837")
|
@app.post("/api/batches/{batch_id}/export-837", dependencies=[Depends(matrix_gate)])
|
||||||
def export_batch_837(request: Request, batch_id: str, body: dict):
|
def export_batch_837(request: Request, batch_id: str, body: dict):
|
||||||
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
||||||
|
|
||||||
@@ -1514,12 +1570,17 @@ def export_batch_837(request: Request, batch_id: str, body: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/inbox/export.csv")
|
@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_export_csv(lane: str):
|
def inbox_export_csv(lane: str, request: Request):
|
||||||
"""Stream a CSV for a single lane."""
|
"""Stream a CSV for a single lane.
|
||||||
|
|
||||||
|
Uses ``request.app.state`` rather than the module-level ``app``
|
||||||
|
global so the endpoint is robust against ``importlib.reload`` of
|
||||||
|
this module (see ``inbox_dismiss_candidates`` for context).
|
||||||
|
"""
|
||||||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||||||
raise HTTPException(400, f"unknown lane: {lane}")
|
raise HTTPException(400, f"unknown lane: {lane}")
|
||||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
|
||||||
with db.SessionLocal()() as session:
|
with db.SessionLocal()() as session:
|
||||||
from cyclone.inbox_lanes import compute_lanes
|
from cyclone.inbox_lanes import compute_lanes
|
||||||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||||||
@@ -1567,7 +1628,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batches")
|
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
||||||
def list_batches(
|
def list_batches(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
@@ -1601,7 +1662,7 @@ def list_batches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batches/{batch_id}")
|
@app.get("/api/batches/{batch_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_batch(batch_id: str) -> Any:
|
def get_batch(batch_id: str) -> Any:
|
||||||
rec = store.get(batch_id)
|
rec = store.get(batch_id)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
@@ -1612,7 +1673,7 @@ def get_batch(batch_id: str) -> Any:
|
|||||||
return json.loads(rec.result.model_dump_json())
|
return json.loads(rec.result.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims")
|
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
|
||||||
def list_claims(
|
def list_claims(
|
||||||
request: Request,
|
request: Request,
|
||||||
batch_id: str | None = Query(None),
|
batch_id: str | None = Query(None),
|
||||||
@@ -1658,7 +1719,7 @@ def list_claims(
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/stream")
|
@app.get("/api/claims/stream", dependencies=[Depends(matrix_gate)])
|
||||||
async def claims_stream(
|
async def claims_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
@@ -1705,7 +1766,7 @@ async def claims_stream(
|
|||||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}")
|
@app.get("/api/claims/{claim_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_claim_detail_endpoint(claim_id: str) -> dict:
|
def get_claim_detail_endpoint(claim_id: str) -> dict:
|
||||||
"""Return one claim with full drawer context (SP4).
|
"""Return one claim with full drawer context (SP4).
|
||||||
|
|
||||||
@@ -1730,7 +1791,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}/serialize-837")
|
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
|
||||||
def serialize_claim_as_837(claim_id: str):
|
def serialize_claim_as_837(claim_id: str):
|
||||||
"""Return the claim as a regenerated X12 837P file (SP8).
|
"""Return the claim as a regenerated X12 837P file (SP8).
|
||||||
|
|
||||||
@@ -1782,7 +1843,7 @@ def serialize_claim_as_837(claim_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}/line-reconciliation")
|
@app.get("/api/claims/{claim_id}/line-reconciliation", dependencies=[Depends(matrix_gate)])
|
||||||
def get_claim_line_reconciliation(claim_id: str) -> dict:
|
def get_claim_line_reconciliation(claim_id: str) -> dict:
|
||||||
"""Per-line reconciliation view for the ClaimDrawer tab.
|
"""Per-line reconciliation view for the ClaimDrawer tab.
|
||||||
|
|
||||||
@@ -1979,7 +2040,7 @@ def _svc_to_dict(svc) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/reconciliation/unmatched")
|
@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)])
|
||||||
def get_reconciliation_unmatched() -> dict:
|
def get_reconciliation_unmatched() -> dict:
|
||||||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||||||
|
|
||||||
@@ -1996,7 +2057,7 @@ def get_reconciliation_unmatched() -> dict:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batch-diff")
|
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
|
||||||
def get_batch_diff(
|
def get_batch_diff(
|
||||||
a: str | None = Query(None),
|
a: str | None = Query(None),
|
||||||
b: str | None = Query(None),
|
b: str | None = Query(None),
|
||||||
@@ -2043,7 +2104,7 @@ def get_batch_diff(
|
|||||||
return diff_batches_to_wire(a_rec, b_rec)
|
return diff_batches_to_wire(a_rec, b_rec)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reconciliation/match")
|
@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)])
|
||||||
def post_reconciliation_match(body: dict) -> dict:
|
def post_reconciliation_match(body: dict) -> dict:
|
||||||
"""Manually pair a Claim with a Remittance (operator override).
|
"""Manually pair a Claim with a Remittance (operator override).
|
||||||
|
|
||||||
@@ -2088,7 +2149,7 @@ def post_reconciliation_match(body: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reconciliation/unmatch")
|
@app.post("/api/reconciliation/unmatch", dependencies=[Depends(matrix_gate)])
|
||||||
def post_reconciliation_unmatch(body: dict) -> dict:
|
def post_reconciliation_unmatch(body: dict) -> dict:
|
||||||
"""Remove the current match for a Claim; reset Claim to submitted.
|
"""Remove the current match for a Claim; reset Claim to submitted.
|
||||||
|
|
||||||
@@ -2120,7 +2181,7 @@ def post_reconciliation_unmatch(body: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances")
|
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
|
||||||
def list_remittances(
|
def list_remittances(
|
||||||
request: Request,
|
request: Request,
|
||||||
batch_id: str | None = Query(None),
|
batch_id: str | None = Query(None),
|
||||||
@@ -2159,7 +2220,7 @@ def list_remittances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances/stream")
|
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
|
||||||
async def remittances_stream(
|
async def remittances_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
payer: str | None = Query(None),
|
payer: str | None = Query(None),
|
||||||
@@ -2198,7 +2259,7 @@ async def remittances_stream(
|
|||||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances/{remittance_id}")
|
@app.get("/api/remittances/{remittance_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_remittance(remittance_id: str) -> dict:
|
def get_remittance(remittance_id: str) -> dict:
|
||||||
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
||||||
|
|
||||||
@@ -2215,7 +2276,7 @@ def get_remittance(remittance_id: str) -> dict:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/providers")
|
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_providers(
|
def list_providers(
|
||||||
request: Request,
|
request: Request,
|
||||||
npi: str | None = Query(None),
|
npi: str | None = Query(None),
|
||||||
@@ -2245,7 +2306,7 @@ def list_providers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/activity")
|
@app.get("/api/activity", dependencies=[Depends(matrix_gate)])
|
||||||
def list_activity(
|
def list_activity(
|
||||||
request: Request,
|
request: Request,
|
||||||
kind: str | None = Query(None),
|
kind: str | None = Query(None),
|
||||||
@@ -2272,7 +2333,7 @@ def list_activity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/activity/stream")
|
@app.get("/api/activity/stream", dependencies=[Depends(matrix_gate)])
|
||||||
async def activity_stream(
|
async def activity_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
kind: str | None = Query(None),
|
kind: str | None = Query(None),
|
||||||
@@ -2420,7 +2481,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
|
|||||||
return result, service_type_code
|
return result, service_type_code
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/eligibility/request")
|
@app.post("/api/eligibility/request", dependencies=[Depends(matrix_gate)])
|
||||||
def post_eligibility_request(body: dict) -> Any:
|
def post_eligibility_request(body: dict) -> Any:
|
||||||
"""Build a 270 eligibility inquiry from a small JSON body.
|
"""Build a 270 eligibility inquiry from a small JSON body.
|
||||||
|
|
||||||
@@ -2446,7 +2507,7 @@ def post_eligibility_request(body: dict) -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/eligibility/parse-271")
|
@app.post("/api/eligibility/parse-271", dependencies=[Depends(matrix_gate)])
|
||||||
async def post_eligibility_parse_271(
|
async def post_eligibility_parse_271(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -2506,7 +2567,7 @@ async def post_eligibility_parse_271(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/clearhouse")
|
@app.get("/api/clearhouse", dependencies=[Depends(matrix_gate)])
|
||||||
def get_clearhouse():
|
def get_clearhouse():
|
||||||
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
|
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
|
||||||
ch = store.get_clearhouse()
|
ch = store.get_clearhouse()
|
||||||
@@ -2515,8 +2576,8 @@ def get_clearhouse():
|
|||||||
return json.loads(ch.model_dump_json())
|
return json.loads(ch.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/clearhouse/submit")
|
@app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)])
|
||||||
def submit_to_clearhouse(body: dict):
|
def submit_to_clearhouse(request: Request, body: dict):
|
||||||
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
||||||
|
|
||||||
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
|
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
|
||||||
@@ -2644,6 +2705,7 @@ def submit_to_clearhouse(body: dict):
|
|||||||
"stub": ch.sftp_block.stub,
|
"stub": ch.sftp_block.stub,
|
||||||
},
|
},
|
||||||
actor="clearhouse-submit",
|
actor="clearhouse-submit",
|
||||||
|
user_id=_actor_user_id(request),
|
||||||
))
|
))
|
||||||
audit_s.commit()
|
audit_s.commit()
|
||||||
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
|
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
|
||||||
@@ -2701,7 +2763,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/providers")
|
@app.get("/api/config/providers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_configured_providers(is_active: bool | None = Query(default=True)):
|
def list_configured_providers(is_active: bool | None = Query(default=True)):
|
||||||
"""List the configured provider rows (3 NPIs for SP9)."""
|
"""List the configured provider rows (3 NPIs for SP9)."""
|
||||||
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
|
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
|
||||||
@@ -2712,7 +2774,7 @@ def list_configured_providers(is_active: bool | None = Query(default=True)):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/audit-log")
|
@app.get("/api/admin/audit-log", dependencies=[Depends(matrix_gate)])
|
||||||
def list_audit_log_endpoint(
|
def list_audit_log_endpoint(
|
||||||
entity_type: str | None = Query(default=None),
|
entity_type: str | None = Query(default=None),
|
||||||
entity_id: str | None = Query(default=None),
|
entity_id: str | None = Query(default=None),
|
||||||
@@ -2754,7 +2816,7 @@ def list_audit_log_endpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/audit-log/verify")
|
@app.get("/api/admin/audit-log/verify", dependencies=[Depends(matrix_gate)])
|
||||||
def verify_audit_log_endpoint() -> Any:
|
def verify_audit_log_endpoint() -> Any:
|
||||||
"""Walk the audit-log chain and verify every row's hash.
|
"""Walk the audit-log chain and verify every row's hash.
|
||||||
|
|
||||||
@@ -2794,7 +2856,7 @@ from cyclone import secrets as _secrets
|
|||||||
_db_rotate_lock = _threading.Lock()
|
_db_rotate_lock = _threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/db/rotate-key")
|
@app.post("/api/admin/db/rotate-key", dependencies=[Depends(matrix_gate)])
|
||||||
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
|
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
|
||||||
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
|
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
|
||||||
|
|
||||||
@@ -2954,7 +3016,7 @@ def _backup_or_503():
|
|||||||
raise HTTPException(status_code=503, detail=str(exc))
|
raise HTTPException(status_code=503, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/create")
|
@app.post("/api/admin/backup/create", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_create() -> Any:
|
def backup_create() -> Any:
|
||||||
"""Take an encrypted backup right now. Returns the new backup metadata."""
|
"""Take an encrypted backup right now. Returns the new backup metadata."""
|
||||||
from cyclone import audit_log as _audit
|
from cyclone import audit_log as _audit
|
||||||
@@ -3008,7 +3070,7 @@ def backup_create() -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/backup/list")
|
@app.get("/api/admin/backup/list", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_list(
|
def backup_list(
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
@@ -3037,7 +3099,7 @@ def backup_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/backup/status")
|
@app.get("/api/admin/backup/status", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_status() -> Any:
|
def backup_status() -> Any:
|
||||||
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
|
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
|
||||||
svc = _backup_or_503()
|
svc = _backup_or_503()
|
||||||
@@ -3051,7 +3113,7 @@ def backup_status() -> Any:
|
|||||||
return snap
|
return snap
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/{backup_id}/verify")
|
@app.post("/api/admin/backup/{backup_id}/verify", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_verify(backup_id: int) -> Any:
|
def backup_verify(backup_id: int) -> Any:
|
||||||
"""Decrypt + checksum-verify a backup against its sidecar."""
|
"""Decrypt + checksum-verify a backup against its sidecar."""
|
||||||
svc = _backup_or_503()
|
svc = _backup_or_503()
|
||||||
@@ -3070,7 +3132,7 @@ def backup_verify(backup_id: int) -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/{backup_id}/restore/initiate")
|
@app.post("/api/admin/backup/{backup_id}/restore/initiate", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_restore_initiate(backup_id: int) -> Any:
|
def backup_restore_initiate(backup_id: int) -> Any:
|
||||||
"""First step of the two-step restore. Returns a ``restore_token``."""
|
"""First step of the two-step restore. Returns a ``restore_token``."""
|
||||||
svc = _backup_or_503()
|
svc = _backup_or_503()
|
||||||
@@ -3098,7 +3160,7 @@ def backup_restore_initiate(backup_id: int) -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/{backup_id}/restore/confirm")
|
@app.post("/api/admin/backup/{backup_id}/restore/confirm", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_restore_confirm(
|
def backup_restore_confirm(
|
||||||
backup_id: int,
|
backup_id: int,
|
||||||
body: dict | None = None,
|
body: dict | None = None,
|
||||||
@@ -3150,7 +3212,7 @@ def backup_restore_confirm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/prune")
|
@app.post("/api/admin/backup/prune", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_prune() -> Any:
|
def backup_prune() -> Any:
|
||||||
"""Apply the retention policy now. Returns the deleted paths."""
|
"""Apply the retention policy now. Returns the deleted paths."""
|
||||||
from cyclone import audit_log as _audit
|
from cyclone import audit_log as _audit
|
||||||
@@ -3178,7 +3240,7 @@ def backup_prune() -> Any:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/scheduler/start")
|
@app.post("/api/admin/backup/scheduler/start", dependencies=[Depends(matrix_gate)])
|
||||||
async def backup_scheduler_start() -> Any:
|
async def backup_scheduler_start() -> Any:
|
||||||
"""Begin the backup scheduler loop."""
|
"""Begin the backup scheduler loop."""
|
||||||
try:
|
try:
|
||||||
@@ -3189,7 +3251,7 @@ async def backup_scheduler_start() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/scheduler/stop")
|
@app.post("/api/admin/backup/scheduler/stop", dependencies=[Depends(matrix_gate)])
|
||||||
async def backup_scheduler_stop() -> Any:
|
async def backup_scheduler_stop() -> Any:
|
||||||
"""Stop the backup scheduler loop."""
|
"""Stop the backup scheduler loop."""
|
||||||
try:
|
try:
|
||||||
@@ -3200,7 +3262,7 @@ async def backup_scheduler_stop() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/scheduler/tick")
|
@app.post("/api/admin/backup/scheduler/tick", dependencies=[Depends(matrix_gate)])
|
||||||
async def backup_scheduler_tick() -> Any:
|
async def backup_scheduler_tick() -> Any:
|
||||||
"""Run one backup tick now (create + prune + audit)."""
|
"""Run one backup tick now (create + prune + audit)."""
|
||||||
try:
|
try:
|
||||||
@@ -3234,7 +3296,7 @@ def _scheduler_or_503():
|
|||||||
raise HTTPException(status_code=503, detail=str(exc))
|
raise HTTPException(status_code=503, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/scheduler/start")
|
@app.post("/api/admin/scheduler/start", dependencies=[Depends(matrix_gate)])
|
||||||
async def scheduler_start() -> Any:
|
async def scheduler_start() -> Any:
|
||||||
"""Begin polling the MFT inbound path every poll_interval_seconds."""
|
"""Begin polling the MFT inbound path every poll_interval_seconds."""
|
||||||
sched = _scheduler_or_503()
|
sched = _scheduler_or_503()
|
||||||
@@ -3242,7 +3304,7 @@ async def scheduler_start() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/scheduler/stop")
|
@app.post("/api/admin/scheduler/stop", dependencies=[Depends(matrix_gate)])
|
||||||
async def scheduler_stop() -> Any:
|
async def scheduler_stop() -> Any:
|
||||||
"""Stop polling. Waits up to 30s for the current tick to finish."""
|
"""Stop polling. Waits up to 30s for the current tick to finish."""
|
||||||
sched = _scheduler_or_503()
|
sched = _scheduler_or_503()
|
||||||
@@ -3250,7 +3312,7 @@ async def scheduler_stop() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/scheduler/tick")
|
@app.post("/api/admin/scheduler/tick", dependencies=[Depends(matrix_gate)])
|
||||||
async def scheduler_tick() -> Any:
|
async def scheduler_tick() -> Any:
|
||||||
"""Run a single poll cycle synchronously and return the result.
|
"""Run a single poll cycle synchronously and return the result.
|
||||||
|
|
||||||
@@ -3263,14 +3325,14 @@ async def scheduler_tick() -> Any:
|
|||||||
return {"ok": True, "tick": result.as_dict()}
|
return {"ok": True, "tick": result.as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/scheduler/status")
|
@app.get("/api/admin/scheduler/status", dependencies=[Depends(matrix_gate)])
|
||||||
def scheduler_status() -> Any:
|
def scheduler_status() -> Any:
|
||||||
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
|
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
|
||||||
sched = _scheduler_or_503()
|
sched = _scheduler_or_503()
|
||||||
return sched.status().as_dict()
|
return sched.status().as_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/scheduler/processed-files")
|
@app.get("/api/admin/scheduler/processed-files", dependencies=[Depends(matrix_gate)])
|
||||||
def scheduler_processed_files(
|
def scheduler_processed_files(
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
@@ -3318,7 +3380,7 @@ def scheduler_processed_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/providers/{npi}")
|
@app.get("/api/config/providers/{npi}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_configured_provider(npi: str):
|
def get_configured_provider(npi: str):
|
||||||
p = store.get_provider(npi)
|
p = store.get_provider(npi)
|
||||||
if p is None:
|
if p is None:
|
||||||
@@ -3392,12 +3454,12 @@ def get_configured_provider(npi: str):
|
|||||||
return provider_dict
|
return provider_dict
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/payers")
|
@app.get("/api/config/payers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_configured_payers(is_active: bool | None = Query(default=True)):
|
def list_configured_payers(is_active: bool | None = Query(default=True)):
|
||||||
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
|
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/payers/{payer_id}/configs")
|
@app.get("/api/config/payers/{payer_id}/configs", dependencies=[Depends(matrix_gate)])
|
||||||
def list_payer_configs(payer_id: str):
|
def list_payer_configs(payer_id: str):
|
||||||
"""List all (transaction_type, config_json) blocks for a payer."""
|
"""List all (transaction_type, config_json) blocks for a payer."""
|
||||||
from cyclone import payers as payer_loader
|
from cyclone import payers as payer_loader
|
||||||
@@ -3448,7 +3510,7 @@ def _clear_summary_cache() -> None:
|
|||||||
# UI proves TTL-bounded staleness is unacceptable.
|
# UI proves TTL-bounded staleness is unacceptable.
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/payers/{payer_id}/summary")
|
@app.get("/api/payers/{payer_id}/summary", dependencies=[Depends(matrix_gate)])
|
||||||
def get_payer_summary(payer_id: str):
|
def get_payer_summary(payer_id: str):
|
||||||
"""Payer-level rollup for the drill-down panel.
|
"""Payer-level rollup for the drill-down panel.
|
||||||
|
|
||||||
@@ -3534,7 +3596,7 @@ def get_payer_summary(payer_id: str):
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/reload-config")
|
@app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
|
||||||
def reload_config():
|
def reload_config():
|
||||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||||
from cyclone import payers as payer_loader
|
from cyclone import payers as payer_loader
|
||||||
@@ -3545,4 +3607,18 @@ def reload_config():
|
|||||||
return {"ok": True, "loaded": len(configs), "errors": []}
|
return {"ok": True, "loaded": len(configs), "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Auth routers (login/logout/me + admin user management) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
from cyclone.auth.routes import router as auth_router
|
||||||
|
from cyclone.auth.admin import router as admin_users_router
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["app"]
|
__all__ = ["app"]
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ class AuditEvent:
|
|||||||
computed hash. Payload must be JSON-serializable; the audit_log
|
computed hash. Payload must be JSON-serializable; the audit_log
|
||||||
module handles the encoding so callers don't need to think about
|
module handles the encoding so callers don't need to think about
|
||||||
canonical form.
|
canonical form.
|
||||||
|
|
||||||
|
``user_id`` is the authenticated actor for this event, when known
|
||||||
|
(e.g. a parse-999 call made by user 7). It's stored on the row but
|
||||||
|
is NOT part of the hash chain — the chain hashes only the fields
|
||||||
|
that existed pre-SP-auth so verify_chain stays compatible with
|
||||||
|
pre-auth rows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -111,6 +117,7 @@ class AuditEvent:
|
|||||||
payload: dict[str, Any] = field(default_factory=dict)
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
actor: str = "system"
|
actor: str = "system"
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def append_event(
|
def append_event(
|
||||||
@@ -155,6 +162,7 @@ def append_event(
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
prev_hash=prev_hash,
|
prev_hash=prev_hash,
|
||||||
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
||||||
|
user_id=event.user_id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush() # populate row.id
|
session.flush() # populate row.id
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.deps import get_current_user
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if user.get("role") != Role.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role(role: str) -> None:
|
||||||
|
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
|
||||||
|
if role not in valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"role must be one of {sorted(valid)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_users(_admin=Depends(_require_admin)):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
all_users = db.query(User).all()
|
||||||
|
return [users.to_public(u) for u in all_users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(body: dict, _admin=Depends(_require_admin)):
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = body.get("password") or ""
|
||||||
|
role = body.get("role") or ""
|
||||||
|
if not username or len(username) < 3:
|
||||||
|
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
|
||||||
|
if len(password) < 12:
|
||||||
|
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
|
||||||
|
_validate_role(role)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if users.get_by_username(db, username) is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists")
|
||||||
|
u = users.create(db, username=username, password=password, role=role)
|
||||||
|
return users.to_public(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
|
||||||
|
me = admin
|
||||||
|
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="cannot_demote_self",
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if body.get("role") is not None:
|
||||||
|
_validate_role(body["role"])
|
||||||
|
users.update_role(db, user_id, body["role"])
|
||||||
|
if body.get("password") is not None:
|
||||||
|
if len(body["password"]) < 12:
|
||||||
|
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
|
||||||
|
users.update_password(db, user_id, body["password"])
|
||||||
|
if body.get("disabled") is True:
|
||||||
|
users.disable(db, user_id)
|
||||||
|
u = users.get(db, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="user not found")
|
||||||
|
return users.to_public(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(user_id: int, admin=Depends(_require_admin)):
|
||||||
|
me = admin
|
||||||
|
if me.get("id") == user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="cannot_delete_self",
|
||||||
|
)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get(db, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="user not found")
|
||||||
|
users.disable(db, user_id)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""First-admin bootstrap: create the initial admin from env vars if no users exist.
|
||||||
|
|
||||||
|
Called from ``python -m cyclone`` before either ``cli.main()`` or
|
||||||
|
``uvicorn`` so users exist by the time the API serves requests.
|
||||||
|
|
||||||
|
Precedence:
|
||||||
|
|
||||||
|
1. ``CYCLONE_AUTH_DISABLED=1`` — dev escape hatch. Flip the
|
||||||
|
``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a
|
||||||
|
synthetic admin user without checking credentials. Never raises.
|
||||||
|
2. Users table non-empty — no-op.
|
||||||
|
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
|
||||||
|
(password >= 12 chars) — create the admin and print confirmation.
|
||||||
|
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
|
||||||
|
points operators at ``python -m cyclone users create``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Bootstrap the first admin user, or no-op.
|
||||||
|
|
||||||
|
See module docstring for behavior. Idempotent: safe to call on
|
||||||
|
every startup — it short-circuits as soon as the users table is
|
||||||
|
non-empty.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CYCLONE_AUTH_DISABLED") == "1":
|
||||||
|
# Dev escape hatch — skip bootstrap entirely and tell the API
|
||||||
|
# to also short-circuit auth checks.
|
||||||
|
import cyclone.auth.deps as _deps
|
||||||
|
|
||||||
|
_deps.AUTH_DISABLED = True
|
||||||
|
return
|
||||||
|
|
||||||
|
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
|
||||||
|
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
# First-boot fix: ``python -m cyclone`` calls bootstrap before any
|
||||||
|
# subcommand or the FastAPI lifespan handler runs, so on a brand-new
|
||||||
|
# DB ``SessionLocal()`` raises "init_db() has not been called".
|
||||||
|
# Initialize here so ``serve``, ``users create``, and friends can
|
||||||
|
# all reach the DB without the operator having to know about
|
||||||
|
# migrations. Idempotent — no-op when the schema is already current.
|
||||||
|
from cyclone import db as _db
|
||||||
|
|
||||||
|
_db.init_db()
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
existing = db.execute(select(User)).scalars().first()
|
||||||
|
if existing is not None:
|
||||||
|
return # users exist — nothing to bootstrap
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
|
||||||
|
"CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run "
|
||||||
|
"`python -m cyclone users create <username> --role admin`."
|
||||||
|
)
|
||||||
|
if len(password) < 12:
|
||||||
|
raise RuntimeError(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD must be at least 12 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
users.create(
|
||||||
|
db,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
role=Role.ADMIN.value,
|
||||||
|
)
|
||||||
|
print(f"[cyclone] bootstrap admin user '{username}' created")
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""CLI subcommand: ``python -m cyclone users ...``.
|
||||||
|
|
||||||
|
Click-based to match the existing parse-837 / parse-835 convention in
|
||||||
|
``cyclone.cli``. Provides operator-side user management without going
|
||||||
|
through the admin HTTP API.
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
-----------
|
||||||
|
|
||||||
|
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
|
||||||
|
Create a user. If ``--password`` is omitted, prompts (with
|
||||||
|
confirmation) on the controlling terminal.
|
||||||
|
- ``users list`` — tab-separated ``id / username / role / state``.
|
||||||
|
- ``users disable USERNAME`` — set ``disabled_at`` to now.
|
||||||
|
- ``users reset-password USERNAME [--password PW]`` — replace the hash.
|
||||||
|
- ``users set-role USERNAME --role {admin,user,viewer}`` — change role.
|
||||||
|
|
||||||
|
Exit codes
|
||||||
|
----------
|
||||||
|
|
||||||
|
* 0 — success
|
||||||
|
* 1 — validation error (unknown username, duplicate username)
|
||||||
|
* 2 — usage error (missing arg, bad role, short password, unknown
|
||||||
|
subcommand). Click itself uses 2 for usage errors so the conventional
|
||||||
|
shell tools (``set -e``, etc.) recognize them.
|
||||||
|
|
||||||
|
Passwords shorter than 12 chars are rejected everywhere they appear.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
|
||||||
|
MIN_PASSWORD_LEN = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_password(label: str) -> str:
|
||||||
|
pw = getpass.getpass(f"{label}: ")
|
||||||
|
if not pw:
|
||||||
|
click.echo("Password required.", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
return pw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_password(pw: str) -> None:
|
||||||
|
if len(pw) < MIN_PASSWORD_LEN:
|
||||||
|
click.echo(
|
||||||
|
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Group
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(name="users")
|
||||||
|
def users_cli() -> None:
|
||||||
|
"""Manage Cyclone users from the command line."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# create
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("create")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||||
|
help="Role to grant the new user.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--password",
|
||||||
|
default=None,
|
||||||
|
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||||
|
)
|
||||||
|
def create_user(username: str, role: str, password: str | None) -> None:
|
||||||
|
"""Create a new user with the given USERNAME and ROLE."""
|
||||||
|
pw = password or _prompt_password("Password")
|
||||||
|
_validate_password(pw)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if users.get_by_username(db, username) is not None:
|
||||||
|
click.echo(f"User '{username}' already exists.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
u = users.create(db, username=username, password=pw, role=role)
|
||||||
|
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# list
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("list")
|
||||||
|
def list_users() -> None:
|
||||||
|
"""List all users as id / username / role / state."""
|
||||||
|
from cyclone.db import User
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
for u in db.query(User).order_by(User.id.asc()).all():
|
||||||
|
state = "disabled" if u.disabled_at else "active"
|
||||||
|
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# disable
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("disable")
|
||||||
|
@click.argument("username")
|
||||||
|
def disable_user(username: str) -> None:
|
||||||
|
"""Disable USERNAME (sets disabled_at to now)."""
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.disable(db, u.id)
|
||||||
|
click.echo(f"Disabled '{username}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# reset-password
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("reset-password")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--password",
|
||||||
|
default=None,
|
||||||
|
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||||
|
)
|
||||||
|
def reset_password(username: str, password: str | None) -> None:
|
||||||
|
"""Replace USERNAME's password."""
|
||||||
|
pw = password or _prompt_password("New password")
|
||||||
|
_validate_password(pw)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.update_password(db, u.id, pw)
|
||||||
|
click.echo(f"Password reset for '{username}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# set-role
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("set-role")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||||
|
help="Role to grant.",
|
||||||
|
)
|
||||||
|
def set_role(username: str, role: str) -> None:
|
||||||
|
"""Change USERNAME's role."""
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.update_role(db, u.id, role)
|
||||||
|
click.echo(f"Role for '{username}' set to '{role}'.")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""FastAPI dependencies for auth."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from cyclone.auth import sessions, users
|
||||||
|
from cyclone.auth.permissions import Role, allowed_roles
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
db = SessionLocal()()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
DbSessionDep = Annotated[DbSession, Depends(_db)]
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_DISABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: DbSessionDep,
|
||||||
|
) -> dict:
|
||||||
|
"""Return the public User shape. Raises 401 if session is missing/expired.
|
||||||
|
|
||||||
|
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
|
||||||
|
user without checking credentials.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return {
|
||||||
|
"id": 0,
|
||||||
|
"username": "dev",
|
||||||
|
"role": Role.ADMIN.value,
|
||||||
|
"createdAt": None,
|
||||||
|
"disabledAt": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
sid = request.cookies.get("cyclone_session")
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="session_expired",
|
||||||
|
)
|
||||||
|
sess = sessions.get_valid(db, sid)
|
||||||
|
if sess is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="session_expired",
|
||||||
|
)
|
||||||
|
user = users.get(db, sess.user_id)
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="account_disabled",
|
||||||
|
)
|
||||||
|
# Sliding expiry: refresh both DB and cookie.
|
||||||
|
sessions.touch(db, sid)
|
||||||
|
request.state.user = user
|
||||||
|
request.state.session_id = sid
|
||||||
|
return users.to_public(user)
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*allowed: Role):
|
||||||
|
"""Dependency factory: gate the endpoint to specific roles.
|
||||||
|
|
||||||
|
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
|
||||||
|
"""
|
||||||
|
async def _dep(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
user_role = user.get("role")
|
||||||
|
if allowed:
|
||||||
|
if user_role not in {r.value for r in allowed}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
# Otherwise consult the matrix.
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
roles = allowed_roles(method, path)
|
||||||
|
if roles is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
async def matrix_gate(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
|
||||||
|
* No session cookie → 401 from get_current_user.
|
||||||
|
* Endpoint not in the matrix → 403 (fail-closed).
|
||||||
|
* User role not allowed for (method, path) → 403.
|
||||||
|
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
|
||||||
|
|
||||||
|
Used as ``dependencies=[Depends(matrix_gate)]`` on every authenticated
|
||||||
|
route. Centralizing the gate here means the matrix is the source of
|
||||||
|
truth — no need to wire per-route ``require_role(...)`` calls.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return user
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
roles = allowed_roles(method, path)
|
||||||
|
if roles is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
user_role = user.get("role")
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Role enum + PERMISSIONS matrix."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
USER = "user"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
|
||||||
|
WRITE_ROLES = {Role.ADMIN, Role.USER}
|
||||||
|
ADMIN_ONLY = {Role.ADMIN}
|
||||||
|
|
||||||
|
|
||||||
|
# (method, path-prefix) → allowed roles.
|
||||||
|
# Endpoints not in this matrix default to DENY (fail-closed).
|
||||||
|
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
|
||||||
|
# Public paths.
|
||||||
|
("GET", "/api/healthz"): set(),
|
||||||
|
("POST", "/api/auth/login"): set(),
|
||||||
|
|
||||||
|
# Auth surface.
|
||||||
|
("POST", "/api/auth/logout"): ALL_ROLES,
|
||||||
|
("GET", "/api/auth/me"): ALL_ROLES,
|
||||||
|
|
||||||
|
# Admin-only user management.
|
||||||
|
("GET", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("POST", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("PATCH", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("DELETE", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
|
||||||
|
# Read endpoints (all authenticated roles).
|
||||||
|
("GET", "/api/claims"): ALL_ROLES,
|
||||||
|
("GET", "/api/remittances"): ALL_ROLES,
|
||||||
|
("GET", "/api/providers"): ALL_ROLES,
|
||||||
|
("GET", "/api/batches"): ALL_ROLES,
|
||||||
|
("GET", "/api/dashboard/summary"): ALL_ROLES,
|
||||||
|
("GET", "/api/activity"): ALL_ROLES,
|
||||||
|
("GET", "/api/inbox/lanes"): ALL_ROLES,
|
||||||
|
("GET", "/api/inbox/export.csv"): ALL_ROLES,
|
||||||
|
("GET", "/api/reconcile"): ALL_ROLES,
|
||||||
|
("GET", "/api/audit-log"): ADMIN_ONLY,
|
||||||
|
|
||||||
|
# Write endpoints (admin + user, no viewer).
|
||||||
|
("POST", "/api/parse-837"): WRITE_ROLES,
|
||||||
|
("POST", "/api/parse-835"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox/candidates"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox/rejected"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox/payer-rejected"): WRITE_ROLES,
|
||||||
|
("POST", "/api/reconcile"): WRITE_ROLES,
|
||||||
|
("POST", "/api/resubmit"): WRITE_ROLES,
|
||||||
|
("POST", "/api/acks"): WRITE_ROLES,
|
||||||
|
|
||||||
|
# CSV export — read-only.
|
||||||
|
("GET", "/api/export.csv"): ALL_ROLES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_roles(method: str, path: str) -> set[Role] | None:
|
||||||
|
"""Return the set of roles allowed to call (method, path), or None if denied.
|
||||||
|
|
||||||
|
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
(len(prefix), roles)
|
||||||
|
for (m, prefix), roles in PERMISSIONS.items()
|
||||||
|
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda x: -x[0])
|
||||||
|
return candidates[0][1]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Per-username login rate limiter (in-memory, per-process)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 300
|
||||||
|
MAX_FAILS = 5
|
||||||
|
|
||||||
|
_FAILS: dict[str, list[float]] = {}
|
||||||
|
_LOCK = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def check(username: str) -> int:
|
||||||
|
"""Return retry-after seconds, or 0 if allowed."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
|
||||||
|
_FAILS[username] = fails
|
||||||
|
if len(fails) >= MAX_FAILS:
|
||||||
|
return int(WINDOW_SECONDS - (now - fails[0]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def record_failure(username: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
_FAILS.setdefault(username, []).append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def reset(username: str) -> None:
|
||||||
|
with _LOCK:
|
||||||
|
_FAILS.pop(username, None)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from cyclone.auth import rate_limit, sessions, users
|
||||||
|
from cyclone.auth.deps import get_current_user
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
COOKIE_NAME = "cyclone_session"
|
||||||
|
COOKIE_MAX_AGE = 86400 # 24h
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https(request: Request) -> bool:
|
||||||
|
if request.url.scheme == "https":
|
||||||
|
return True
|
||||||
|
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, sid: str, request: Request) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=sid,
|
||||||
|
max_age=COOKIE_MAX_AGE,
|
||||||
|
path="/api",
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=_is_https(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(body: dict, request: Request, response: Response):
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = body.get("password") or ""
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="username and password are required",
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_after = rate_limit.check(username)
|
||||||
|
if retry_after > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="rate_limited",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
user = users.get_by_username(db, username)
|
||||||
|
if user is None or not users.verify_password(password, user.password_hash):
|
||||||
|
rate_limit.record_failure(username)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="invalid_credentials",
|
||||||
|
)
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="account_disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
rate_limit.reset(username)
|
||||||
|
public = users.to_public(user)
|
||||||
|
|
||||||
|
_set_cookie(response, sid, request)
|
||||||
|
return public
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sid = request.cookies.get(COOKIE_NAME)
|
||||||
|
if sid:
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sessions.delete(db, sid)
|
||||||
|
_clear_cookie(response)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(user: dict = Depends(get_current_user)):
|
||||||
|
return user
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Session create/validate/expire/touch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.db import Session
|
||||||
|
|
||||||
|
SESSION_LIFETIME = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def create(db, *, user_id: int) -> tuple[str, Session]:
|
||||||
|
sid = secrets.token_urlsafe(32)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + SESSION_LIFETIME
|
||||||
|
sess = Session(
|
||||||
|
id=sid,
|
||||||
|
user_id=user_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(sess)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sess)
|
||||||
|
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
|
||||||
|
sess.expires_at = expires_at
|
||||||
|
return sid, sess
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid(db, sid: str) -> Session | None:
|
||||||
|
sess = db.execute(
|
||||||
|
select(Session).where(Session.id == sid)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sess is None:
|
||||||
|
return None
|
||||||
|
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
|
||||||
|
if sess.expires_at.tzinfo is None:
|
||||||
|
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if sess.expires_at <= datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def delete(db, sid: str) -> None:
|
||||||
|
sess = db.get(Session, sid)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
db.delete(sess)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def touch(db, sid: str) -> None:
|
||||||
|
sess = db.get(Session, sid)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""User CRUD + bcrypt password hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from passlib.hash import bcrypt
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.db import User
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plaintext: str) -> str:
|
||||||
|
return bcrypt.hash(plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.verify(plaintext, hashed)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create(db, *, username: str, password: str, role: str) -> User:
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_username(db, username: str) -> User | None:
|
||||||
|
return db.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def get(db, user_id: int) -> User | None:
|
||||||
|
return db.get(User, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def disable(db, user_id: int) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.disabled_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_role(db, user_id: int, role: str) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.role = role
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_password(db, user_id: int, new_password: str) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def to_public(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
"disabledAt": user.disabled_at.isoformat() if user.disabled_at else None,
|
||||||
|
}
|
||||||
@@ -74,6 +74,18 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
|
|||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
|
|
||||||
|
|
||||||
|
# Register the auth users subgroup. Imported here (not at module top) to
|
||||||
|
# avoid pulling passlib / bcrypt at CLI parse-only import time.
|
||||||
|
from cyclone.auth.cli import users_cli # noqa: E402
|
||||||
|
main.add_command(users_cli)
|
||||||
|
|
||||||
|
# Register the dev seed subcommand. Imported here for the same lazy-load
|
||||||
|
# reason as users_cli — keeps passlib/bcrypt + SQLAlchemy out of the
|
||||||
|
# parse-only path so ``python -m cyclone --help`` stays snappy.
|
||||||
|
from cyclone.seed_cli import seed_cli # noqa: E402
|
||||||
|
main.add_command(seed_cli)
|
||||||
|
|
||||||
|
|
||||||
@main.command("parse-837")
|
@main.command("parse-837")
|
||||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from sqlalchemy import (
|
|||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
func,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||||
@@ -668,6 +669,11 @@ class AuditLog(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# SP-auth: which authenticated user performed this action. Nullable
|
||||||
|
# so existing (pre-auth) rows and system-initiated events stay valid.
|
||||||
|
# NOT part of the hash chain — verify_chain must continue to work on
|
||||||
|
# legacy rows that pre-date this column.
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
||||||
@@ -836,3 +842,27 @@ class ClearhouseORM(Base):
|
|||||||
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Auth user (admin / user / viewer)."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
"""Server-side auth session (HttpOnly cookie holds the id)."""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- version: 13
|
||||||
|
-- Auth (SP-auth): users + sessions tables.
|
||||||
|
--
|
||||||
|
-- `users` holds the local credential store: bcrypt-hashed password,
|
||||||
|
-- role enum ('admin' | 'user' | 'viewer'), and a soft-delete column
|
||||||
|
-- (disabled_at) so admins can revoke access without losing history.
|
||||||
|
--
|
||||||
|
-- `sessions` holds the server-side session rows; the browser only
|
||||||
|
-- carries an opaque token cookie (cyclone_session) that points here.
|
||||||
|
-- expires_at index lets us cheaply reap stale sessions.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
disabled_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- version: 14
|
||||||
|
-- Auth (SP-auth): record the acting user_id on every audit_log entry.
|
||||||
|
--
|
||||||
|
-- Backwards-compatible: existing rows get NULL user_id (they were
|
||||||
|
-- written by the pre-auth `system` actor). Going forward, the FastAPI
|
||||||
|
-- get_current_user dependency injects the id into every audit log call.
|
||||||
|
|
||||||
|
ALTER TABLE audit_log ADD COLUMN user_id INTEGER;
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"""CLI subcommand: ``python -m cyclone seed``.
|
||||||
|
|
||||||
|
Populates the local DB with a deterministic batch of sample claims
|
||||||
|
plus matching activity events, so the Dashboard / Claims / Activity
|
||||||
|
Log pages have something to render in a fresh dev environment.
|
||||||
|
|
||||||
|
This is a dev-only convenience — the production DB never sees this
|
||||||
|
command (no ``[env: dev]`` gate, but it's never wired into any
|
||||||
|
container image). It writes rows with a recognizable batch id prefix
|
||||||
|
(``SEED-``) so ``--reset`` can clean them up without touching real
|
||||||
|
ingested data.
|
||||||
|
|
||||||
|
Why the data shape is hand-rolled here
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
The ``Claim`` ORM table stores its billing_provider / payer /
|
||||||
|
subscriber / service_lines payloads in a ``raw_json`` blob that the
|
||||||
|
read path (``store.to_ui_claim_from_orm``) parses back into the UI
|
||||||
|
shape. Mirroring the 837 parser's structure keeps the UI rendering
|
||||||
|
identical to a real ingestion — wire format parity, not shortcuts.
|
||||||
|
|
||||||
|
Activity rows are similar: ``payload_json`` carries the message
|
||||||
|
string the Dashboard's activity card shows.
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
-----------
|
||||||
|
|
||||||
|
``python -m cyclone seed`` — insert the default batch.
|
||||||
|
``python -m cyclone seed --count N`` — insert N claims (default 96).
|
||||||
|
``python -m cyclone seed --reset`` — wipe previously-seeded rows
|
||||||
|
first, then insert a fresh batch.
|
||||||
|
``python -m cyclone seed --status`` — print row counts without
|
||||||
|
inserting anything.
|
||||||
|
|
||||||
|
Exit codes
|
||||||
|
----------
|
||||||
|
|
||||||
|
* 0 — success (or already seeded and not asked to reset).
|
||||||
|
* 1 — DB error during insert.
|
||||||
|
* 2 — usage error (bad flag value).
|
||||||
|
|
||||||
|
The command is idempotent: re-running without ``--reset`` is a no-op
|
||||||
|
once a seed batch exists. This keeps ``cyclone``-driven boot scripts
|
||||||
|
safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance, SessionLocal
|
||||||
|
|
||||||
|
SEED_BATCH_PREFIX = "SEED-"
|
||||||
|
SEED_CLAIM_PREFIX = "CLM-S"
|
||||||
|
SEED_REMIT_PREFIX = "REM-S"
|
||||||
|
SEED_DEFAULT_COUNT = 96
|
||||||
|
SEED_ACTIVITY_LIMIT = 28
|
||||||
|
|
||||||
|
# Mirror src/data/sampleData.ts on the frontend so the Dashboard looks
|
||||||
|
# the same in dev mode as it did with the in-memory fixtures.
|
||||||
|
SAMPLE_PROVIDERS = [
|
||||||
|
{
|
||||||
|
"npi": "1730187395",
|
||||||
|
"name": "Cedar Park Family Medicine",
|
||||||
|
"tax_id": "47-3829104",
|
||||||
|
"address": "1401 Medical Pkwy",
|
||||||
|
"city": "Cedar Park",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78613",
|
||||||
|
"phone": "(512) 555-0142",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npi": "1528471902",
|
||||||
|
"name": "Lakeside Orthopedics",
|
||||||
|
"tax_id": "83-1172654",
|
||||||
|
"address": "900 W Lake Dr",
|
||||||
|
"city": "Austin",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78746",
|
||||||
|
"phone": "(512) 555-0188",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npi": "1982036471",
|
||||||
|
"name": "Hill Country Pediatrics",
|
||||||
|
"tax_id": "74-5520183",
|
||||||
|
"address": "205 State Hwy 27",
|
||||||
|
"city": "Marble Falls",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78654",
|
||||||
|
"phone": "(830) 555-0117",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_PAYERS = [
|
||||||
|
"Blue Cross Blue Shield",
|
||||||
|
"United Healthcare",
|
||||||
|
"Aetna",
|
||||||
|
"Cigna",
|
||||||
|
"Humana",
|
||||||
|
"Medicare",
|
||||||
|
"Medicaid TX",
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_CPTS = ["99213", "99214", "99203", "93000", "85025", "80053", "73721", "20610"]
|
||||||
|
|
||||||
|
SAMPLE_FIRST_NAMES = [
|
||||||
|
"Avery", "Jordan", "Riley", "Casey", "Morgan",
|
||||||
|
"Quinn", "Reese", "Sasha", "Drew", "Hayden",
|
||||||
|
]
|
||||||
|
SAMPLE_LAST_NAMES = [
|
||||||
|
"Nguyen", "Patel", "Garcia", "Cohen", "Okafor",
|
||||||
|
"Martinez", "Hwang", "Brooks", "Singh", "Tanaka",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Frontend uses "accepted"/"pending" which the backend ClaimState
|
||||||
|
# enum doesn't carry. Map them to the closest real states so the
|
||||||
|
# Dashboard's filters (e.g. "status === 'submitted' || 'pending'")
|
||||||
|
# still find a match for in-flight work.
|
||||||
|
STATUS_WEIGHTS: list[tuple[ClaimState, int]] = [
|
||||||
|
(ClaimState.SUBMITTED, 1),
|
||||||
|
(ClaimState.RECEIVED, 1),
|
||||||
|
(ClaimState.DENIED, 1),
|
||||||
|
(ClaimState.PAID, 3),
|
||||||
|
(ClaimState.PAID, 3),
|
||||||
|
(ClaimState.PARTIAL, 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
DENIAL_REASONS = [
|
||||||
|
"CO-97: Service included in another service",
|
||||||
|
"CO-16: Claim lacks information",
|
||||||
|
"CO-50: Non-covered service",
|
||||||
|
"PR-1: Deductible amount",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(rng: random.Random, items):
|
||||||
|
return items[rng.randint(0, len(items) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_seed_rows(
|
||||||
|
count: int, *, seed: int = 42,
|
||||||
|
) -> tuple[Batch, list[Claim], list[ActivityEvent], list[Remittance]]:
|
||||||
|
"""Build a deterministic Batch + N Claims + matching activity events.
|
||||||
|
|
||||||
|
PAID and PARTIAL claims get a paired ``Remittance`` row (with a
|
||||||
|
realistic ``total_paid`` derived from the billed amount) so the
|
||||||
|
Dashboard's "Received" KPI lights up; the wire shape mirrors what
|
||||||
|
``CycloneStore.iter_claims`` expects to find via
|
||||||
|
``Claim.matched_remittance_id`` → ``Remittance.total_paid``.
|
||||||
|
|
||||||
|
The seed is fixed so re-running produces identical data — keeps
|
||||||
|
screenshots and dev environments stable.
|
||||||
|
"""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
now = _now_utc()
|
||||||
|
parsed_at = now - timedelta(minutes=5)
|
||||||
|
|
||||||
|
batch = Batch(
|
||||||
|
id=f"{SEED_BATCH_PREFIX}{now.strftime('%Y%m%d-%H%M%S')}",
|
||||||
|
kind="837",
|
||||||
|
input_filename="seed/sample-837.edi",
|
||||||
|
parsed_at=parsed_at,
|
||||||
|
totals_json={"claim_count": count},
|
||||||
|
validation_json={"errors": [], "warnings": []},
|
||||||
|
raw_result_json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
claims: list[Claim] = []
|
||||||
|
activity: list[ActivityEvent] = []
|
||||||
|
remittances: list[Remittance] = []
|
||||||
|
seq = 10428 # Match the frontend fixture's id range
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
provider = _pick(rng, SAMPLE_PROVIDERS)
|
||||||
|
payer = _pick(rng, SAMPLE_PAYERS)
|
||||||
|
cpt = _pick(rng, SAMPLE_CPTS)
|
||||||
|
first = _pick(rng, SAMPLE_FIRST_NAMES)
|
||||||
|
last = _pick(rng, SAMPLE_LAST_NAMES)
|
||||||
|
state, _ = _pick(rng, STATUS_WEIGHTS)
|
||||||
|
|
||||||
|
days_back = rng.randint(0, 200)
|
||||||
|
submitted = now - timedelta(days=days_back, hours=rng.randint(0, 23))
|
||||||
|
|
||||||
|
billed = 80 + rng.randint(0, 1400)
|
||||||
|
if state == ClaimState.PAID:
|
||||||
|
received = int(billed * (0.6 + rng.random() * 0.4))
|
||||||
|
elif state == ClaimState.PARTIAL:
|
||||||
|
received = int(billed * (0.2 + rng.random() * 0.3))
|
||||||
|
elif state == ClaimState.DENIED:
|
||||||
|
received = 0
|
||||||
|
else:
|
||||||
|
received = 0
|
||||||
|
|
||||||
|
claim_id = f"{SEED_CLAIM_PREFIX}{(seq + i):05d}"
|
||||||
|
denial_reason = _pick(rng, DENIAL_REASONS) if state == ClaimState.DENIED else None
|
||||||
|
|
||||||
|
# Build a paired Remittance for any claim with received > 0. Status
|
||||||
|
# code 1 = "Primary payer forward" — the 835 CAS code that the
|
||||||
|
# remittance mapper turns into "received". Mirror the 835 parser's
|
||||||
|
# raw_json shape (a stripped provider/payer/service_lines block)
|
||||||
|
# so downstream debug views still render something useful.
|
||||||
|
matched_remit_id: str | None = None
|
||||||
|
if received > 0:
|
||||||
|
matched_remit_id = f"{SEED_REMIT_PREFIX}{(seq + i):05d}"
|
||||||
|
remittances.append(Remittance(
|
||||||
|
id=matched_remit_id,
|
||||||
|
batch_id=batch.id,
|
||||||
|
payer_claim_control_number=f"PCN-{(seq + i):05d}",
|
||||||
|
claim_id=claim_id,
|
||||||
|
status_code="1",
|
||||||
|
status_label="Primary payer forward",
|
||||||
|
total_charge=float(billed),
|
||||||
|
total_paid=float(received),
|
||||||
|
adjustment_amount=float(billed - received),
|
||||||
|
received_at=submitted + timedelta(days=rng.randint(2, 14)),
|
||||||
|
raw_json={
|
||||||
|
"payer": {"name": payer},
|
||||||
|
"provider": {
|
||||||
|
"npi": provider["npi"],
|
||||||
|
"name": provider["name"],
|
||||||
|
},
|
||||||
|
"service_lines": [
|
||||||
|
{
|
||||||
|
"procedure": {"code": cpt},
|
||||||
|
"charge_amount": float(billed),
|
||||||
|
"paid_amount": float(received),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
raw_json = {
|
||||||
|
"billing_provider": {
|
||||||
|
"npi": provider["npi"],
|
||||||
|
"name": provider["name"],
|
||||||
|
"tax_id": provider["tax_id"],
|
||||||
|
"address": provider["address"],
|
||||||
|
"city": provider["city"],
|
||||||
|
"state": provider["state"],
|
||||||
|
"zip": provider["zip"],
|
||||||
|
"phone": provider["phone"],
|
||||||
|
},
|
||||||
|
"payer": {"name": payer},
|
||||||
|
"subscriber": {"first_name": first, "last_name": last},
|
||||||
|
"service_lines": [
|
||||||
|
{
|
||||||
|
"procedure": {"code": cpt},
|
||||||
|
"charge_amount": float(billed),
|
||||||
|
"service_date": submitted.date().isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
claims.append(Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch.id,
|
||||||
|
patient_control_number=claim_id.replace(SEED_CLAIM_PREFIX, "PCN-"),
|
||||||
|
service_date_from=submitted.date(),
|
||||||
|
service_date_to=submitted.date(),
|
||||||
|
charge_amount=billed,
|
||||||
|
provider_npi=provider["npi"],
|
||||||
|
payer_id=None,
|
||||||
|
state=state,
|
||||||
|
state_changed_at=submitted,
|
||||||
|
rejection_reason=denial_reason,
|
||||||
|
resubmit_count=0,
|
||||||
|
matched_remittance_id=matched_remit_id,
|
||||||
|
raw_json=raw_json,
|
||||||
|
))
|
||||||
|
|
||||||
|
# First SEED_ACTIVITY_LIMIT claims get an activity event. Mirrors
|
||||||
|
# the frontend buildActivity() shape (claim_paid / claim_denied /
|
||||||
|
# claim_accepted / claim_submitted) but maps frontend statuses
|
||||||
|
# onto the backend's wire enum.
|
||||||
|
if i < SEED_ACTIVITY_LIMIT:
|
||||||
|
if state == ClaimState.PAID:
|
||||||
|
kind = "claim_paid"
|
||||||
|
verb = "Paid"
|
||||||
|
elif state == ClaimState.DENIED:
|
||||||
|
kind = "claim_denied"
|
||||||
|
verb = "Denied"
|
||||||
|
elif state in (ClaimState.RECEIVED, ClaimState.PARTIAL):
|
||||||
|
kind = "claim_accepted"
|
||||||
|
verb = "Accepted"
|
||||||
|
else:
|
||||||
|
kind = "claim_submitted"
|
||||||
|
verb = "Submitted"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"message": f"{verb} {claim_id} · {first} {last}",
|
||||||
|
"npi": provider["npi"],
|
||||||
|
"amount": float(billed),
|
||||||
|
}
|
||||||
|
activity.append(ActivityEvent(
|
||||||
|
ts=submitted,
|
||||||
|
kind=kind,
|
||||||
|
batch_id=batch.id,
|
||||||
|
claim_id=claim_id,
|
||||||
|
remittance_id=None,
|
||||||
|
payload_json=payload,
|
||||||
|
))
|
||||||
|
|
||||||
|
return batch, claims, activity, remittances
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_seed_batch_ids(s) -> list[str]:
|
||||||
|
rows = s.query(Batch.id).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).all()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_seed_rows(s) -> int:
|
||||||
|
"""Delete every row that was inserted by the seed.
|
||||||
|
|
||||||
|
The seed writes rows whose ids begin with ``SEED-`` (batches),
|
||||||
|
``CLM-S`` (claims), or ``REM-S`` (remittances). Activity events
|
||||||
|
are joined to seeded batches when the batch is still around, but
|
||||||
|
we also clean up orphan activity rows (no FK from
|
||||||
|
``activity_events.claim_id`` to ``claims.id``) by ``claim_id``
|
||||||
|
prefix. Returns the number of batch rows deleted.
|
||||||
|
"""
|
||||||
|
# Activity first — its rows reference both claims and batches, so
|
||||||
|
# delete the events tied to seed claims first, then any that were
|
||||||
|
# only tied to a now-orphaned seed batch.
|
||||||
|
activity = s.query(ActivityEvent).filter(
|
||||||
|
ActivityEvent.claim_id.like(f"{SEED_CLAIM_PREFIX}%")
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
activity2 = s.query(ActivityEvent).filter(
|
||||||
|
ActivityEvent.batch_id.like(f"{SEED_BATCH_PREFIX}%")
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
# Remittances (FK to claims; cascade may not fire without FK pragma,
|
||||||
|
# so we delete them explicitly to clear the symmetric FK first).
|
||||||
|
remits = s.query(Remittance).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).delete(synchronize_session=False)
|
||||||
|
claims = s.query(Claim).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).delete(synchronize_session=False)
|
||||||
|
batches = s.query(Batch).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).delete(synchronize_session=False)
|
||||||
|
s.commit()
|
||||||
|
click.echo(f" Removed {batches} seeded batch(es), {claims} claim(s), "
|
||||||
|
f"{remits} remittance(s), {activity + activity2} activity event(s).")
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(
|
||||||
|
batch: Batch,
|
||||||
|
claims: list[Claim],
|
||||||
|
activity: list[ActivityEvent],
|
||||||
|
remittances: list[Remittance],
|
||||||
|
) -> None:
|
||||||
|
with SessionLocal()() as s:
|
||||||
|
s.add(batch)
|
||||||
|
s.add_all(claims)
|
||||||
|
s.add_all(activity)
|
||||||
|
s.add_all(remittances)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_status(s) -> None:
|
||||||
|
from sqlalchemy import func
|
||||||
|
seed_batches = s.query(func.count(Batch.id)).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).scalar() or 0
|
||||||
|
seed_claims = s.query(func.count(Claim.id)).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).scalar() or 0
|
||||||
|
seed_remits = s.query(func.count(Remittance.id)).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).scalar() or 0
|
||||||
|
total_claims = s.query(func.count(Claim.id)).scalar() or 0
|
||||||
|
total_remits = s.query(func.count(Remittance.id)).scalar() or 0
|
||||||
|
total_activity = s.query(func.count(ActivityEvent.id)).scalar() or 0
|
||||||
|
click.echo(f" Seed batches: {seed_batches}")
|
||||||
|
click.echo(f" Seed claims: {seed_claims}")
|
||||||
|
click.echo(f" Seed remits: {seed_remits}")
|
||||||
|
click.echo(f" Total claims: {total_claims}")
|
||||||
|
click.echo(f" Total remits: {total_remits}")
|
||||||
|
click.echo(f" Total activity: {total_activity}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("seed")
|
||||||
|
@click.option(
|
||||||
|
"--count",
|
||||||
|
default=SEED_DEFAULT_COUNT,
|
||||||
|
show_default=True,
|
||||||
|
type=click.IntRange(min=1, max=10_000),
|
||||||
|
help="Number of claims to insert in the new batch.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--reset",
|
||||||
|
is_flag=True,
|
||||||
|
help="Delete any existing seeded batch (and its claims/activity) before inserting.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--status",
|
||||||
|
"show_status",
|
||||||
|
is_flag=True,
|
||||||
|
help="Print current seeded row counts and exit.",
|
||||||
|
)
|
||||||
|
def seed_cli(count: int, reset: bool, show_status: bool) -> None:
|
||||||
|
"""Populate the local DB with a deterministic batch of sample claims."""
|
||||||
|
with SessionLocal()() as s:
|
||||||
|
if show_status:
|
||||||
|
_print_status(s)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = _existing_seed_batch_ids(s)
|
||||||
|
if existing and not reset:
|
||||||
|
click.echo(
|
||||||
|
f"Seed already present (batch ids: {', '.join(existing)}). "
|
||||||
|
f"Re-run with --reset to replace, or --status to inspect.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if reset and existing:
|
||||||
|
deleted = _delete_seed_rows(s)
|
||||||
|
click.echo(f" Removed {deleted} seeded batch(es).")
|
||||||
|
|
||||||
|
try:
|
||||||
|
batch, claims, activity, remittances = _build_seed_rows(count)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(f"Failed to build seed rows: {exc}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
_insert(batch, claims, activity, remittances)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(f"Failed to insert seed rows: {exc}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(f" Inserted batch {batch.id} with {len(claims)} claims, "
|
||||||
|
f"{len(remittances)} remittances, {len(activity)} activity events.")
|
||||||
|
_print_status(s)
|
||||||
@@ -431,6 +431,7 @@ def to_ui_claim_from_orm(
|
|||||||
*,
|
*,
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
parsed_at: datetime,
|
parsed_at: datetime,
|
||||||
|
received_total: float = 0.0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map an ORM ``Claim`` row to the UI's claim shape.
|
"""Map an ORM ``Claim`` row to the UI's claim shape.
|
||||||
|
|
||||||
@@ -476,7 +477,10 @@ def to_ui_claim_from_orm(
|
|||||||
"batchId": batch_id,
|
"batchId": batch_id,
|
||||||
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
|
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
|
||||||
# but expects these on freshly-loaded rows from /api/claims too.
|
# but expects these on freshly-loaded rows from /api/claims too.
|
||||||
"receivedAmount": 0.0,
|
# ``received_total`` comes from the matched Remittance row when one
|
||||||
|
# exists; callers that don't pre-compute it (write path, unmatched
|
||||||
|
# list) get the default of 0.0 — which matches the unmapped state.
|
||||||
|
"receivedAmount": float(received_total),
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1072,9 @@ class CycloneStore:
|
|||||||
ui = to_ui_claim_from_orm(
|
ui = to_ui_claim_from_orm(
|
||||||
row, batch_id=row.batch_id or record.id,
|
row, batch_id=row.batch_id or record.id,
|
||||||
parsed_at=record.parsed_at,
|
parsed_at=record.parsed_at,
|
||||||
|
# Fresh ingest — no remittance has been paired yet,
|
||||||
|
# so ``Received`` is necessarily 0.
|
||||||
|
received_total=0.0,
|
||||||
)
|
)
|
||||||
self._sync_publish(event_bus, "claim_written", ui)
|
self._sync_publish(event_bus, "claim_written", ui)
|
||||||
for rid in remit_ids:
|
for rid in remit_ids:
|
||||||
@@ -1488,6 +1495,24 @@ class CycloneStore:
|
|||||||
q = q.filter(Claim.provider_npi == provider_npi)
|
q = q.filter(Claim.provider_npi == provider_npi)
|
||||||
|
|
||||||
rows = q.all()
|
rows = q.all()
|
||||||
|
# Bulk-load matched-remittance totals so the UI's "Received"
|
||||||
|
# KPI + per-claim received_amount reflect real paid amounts
|
||||||
|
# rather than always-0. One SQL roundtrip for the whole page
|
||||||
|
# rather than per-claim lookups.
|
||||||
|
matched_ids = [
|
||||||
|
r.matched_remittance_id
|
||||||
|
for r in rows
|
||||||
|
if r.matched_remittance_id
|
||||||
|
]
|
||||||
|
received_by_remit: dict[str, float] = {}
|
||||||
|
if matched_ids:
|
||||||
|
for rid, total_paid in (
|
||||||
|
s.query(Remittance.id, Remittance.total_paid)
|
||||||
|
.filter(Remittance.id.in_(matched_ids))
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
received_by_remit[rid] = float(total_paid or 0)
|
||||||
|
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
raw = r.raw_json or {}
|
raw = r.raw_json or {}
|
||||||
@@ -1516,7 +1541,9 @@ class CycloneStore:
|
|||||||
"payerName": payer_obj.get("name") or "",
|
"payerName": payer_obj.get("name") or "",
|
||||||
"cptCode": cpt,
|
"cptCode": cpt,
|
||||||
"billedAmount": float(r.charge_amount or 0),
|
"billedAmount": float(r.charge_amount or 0),
|
||||||
"receivedAmount": 0.0,
|
"receivedAmount": received_by_remit.get(
|
||||||
|
r.matched_remittance_id, 0.0
|
||||||
|
),
|
||||||
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
|
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
|
||||||
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
|
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
@@ -1936,6 +1963,9 @@ class CycloneStore:
|
|||||||
result["claims"].append(
|
result["claims"].append(
|
||||||
to_ui_claim_from_orm(
|
to_ui_claim_from_orm(
|
||||||
r, batch_id=r.batch_id, parsed_at=parsed_at,
|
r, batch_id=r.batch_id, parsed_at=parsed_at,
|
||||||
|
# list_unmatched filters matched_remittance_id IS NULL,
|
||||||
|
# so every row has no remittance yet.
|
||||||
|
received_total=0.0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2063,6 +2093,7 @@ class CycloneStore:
|
|||||||
)
|
)
|
||||||
claim_dict = to_ui_claim_from_orm(
|
claim_dict = to_ui_claim_from_orm(
|
||||||
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
||||||
|
received_total=float(remit.total_paid or 0),
|
||||||
)
|
)
|
||||||
matched_at_iso = now.isoformat().replace("+00:00", "Z")
|
matched_at_iso = now.isoformat().replace("+00:00", "Z")
|
||||||
return {
|
return {
|
||||||
@@ -2119,9 +2150,11 @@ class CycloneStore:
|
|||||||
# rows exist. Shouldn't happen, but if it does, fall back
|
# rows exist. Shouldn't happen, but if it does, fall back
|
||||||
# to clearing the FK and starting fresh.
|
# to clearing the FK and starting fresh.
|
||||||
latest = None
|
latest = None
|
||||||
|
paired_remit = None
|
||||||
restored_state = ClaimState.SUBMITTED
|
restored_state = ClaimState.SUBMITTED
|
||||||
else:
|
else:
|
||||||
latest = matches[0]
|
latest = matches[0]
|
||||||
|
paired_remit = s.get(Remittance, latest.remittance_id)
|
||||||
restored_state = (
|
restored_state = (
|
||||||
latest.prior_claim_state
|
latest.prior_claim_state
|
||||||
if latest.prior_claim_state is not None
|
if latest.prior_claim_state is not None
|
||||||
@@ -2137,9 +2170,7 @@ class CycloneStore:
|
|||||||
# Clear the symmetric FK on the remittance so list_unmatched
|
# Clear the symmetric FK on the remittance so list_unmatched
|
||||||
# surfaces the pair again. The remittance may have been
|
# surfaces the pair again. The remittance may have been
|
||||||
# deleted between the match and this call — guard with a
|
# deleted between the match and this call — guard with a
|
||||||
# get() so we don't blow up on a stale FK.
|
# None check so we don't blow up on a stale FK.
|
||||||
if latest is not None:
|
|
||||||
paired_remit = s.get(Remittance, latest.remittance_id)
|
|
||||||
if paired_remit is not None:
|
if paired_remit is not None:
|
||||||
paired_remit.claim_id = None
|
paired_remit.claim_id = None
|
||||||
|
|
||||||
@@ -2161,8 +2192,19 @@ class CycloneStore:
|
|||||||
if claim.batch is not None
|
if claim.batch is not None
|
||||||
else now
|
else now
|
||||||
)
|
)
|
||||||
|
# ``paired_remit`` is the matched remittance we cleared in
|
||||||
|
# the unmatch; use its ``total_paid`` for the response shape
|
||||||
|
# so the UI sees what was paid before the unpair. May be
|
||||||
|
# ``None`` if the remittance was deleted since the match —
|
||||||
|
# default to 0.0 in that case.
|
||||||
|
received_total = (
|
||||||
|
float(paired_remit.total_paid or 0)
|
||||||
|
if paired_remit is not None
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
claim_dict = to_ui_claim_from_orm(
|
claim_dict = to_ui_claim_from_orm(
|
||||||
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
||||||
|
received_total=received_total,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"claim": claim_dict,
|
"claim": claim_dict,
|
||||||
|
|||||||
@@ -24,17 +24,34 @@ def _auto_init_db(tmp_path, monkeypatch):
|
|||||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||||
does not invoke the FastAPI lifespan handler unless used as a context
|
does not invoke the FastAPI lifespan handler unless used as a context
|
||||||
manager. The bus is reset between tests so subscribers don't leak.
|
manager. The bus is reset between tests so subscribers don't leak.
|
||||||
|
|
||||||
|
Auth gating is enabled at the router/endpoint level via the
|
||||||
|
``Depends(matrix_gate)`` wiring in ``cyclone.api``. The auth tests
|
||||||
|
(``test_auth_*``) explicitly flip ``AUTH_DISABLED = False`` and
|
||||||
|
authenticate via the public login route to exercise the real
|
||||||
|
auth path. Every other test — the original test suite predates
|
||||||
|
auth — gets ``AUTH_DISABLED = True`` here so the existing tests
|
||||||
|
keep working without each one having to login first.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.api import app
|
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
|
from cyclone.auth import deps
|
||||||
|
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
db.init_db()
|
db.init_db()
|
||||||
app.state.event_bus = EventBus()
|
# Re-resolve `app` each fixture invocation because some tests
|
||||||
|
# (test_cors_extra_origins_via_env) call ``importlib.reload`` on
|
||||||
|
# ``cyclone.api`` to mutate the CORS allow-list. If we cached
|
||||||
|
# the app reference at conftest module load, we'd be setting
|
||||||
|
# ``event_bus`` on a stale instance that no test client is
|
||||||
|
# actually using.
|
||||||
|
from cyclone import api as _api_mod
|
||||||
|
_api_mod.app.state.event_bus = EventBus()
|
||||||
|
deps.AUTH_DISABLED = True
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
app.state.event_bus = None
|
deps.AUTH_DISABLED = False
|
||||||
|
_api_mod.app.state.event_bus = None
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
@@ -51,19 +51,20 @@ def test_migration_0002_creates_acks_table():
|
|||||||
|
|
||||||
def test_migration_latest_idempotent_on_fresh_db():
|
def test_migration_latest_idempotent_on_fresh_db():
|
||||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||||
user_version already at the latest version — currently 12 after
|
user_version already at the latest version — currently 14 after
|
||||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||||
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups)."""
|
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups,
|
||||||
|
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id)."""
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v1 == 12
|
assert v1 == 14
|
||||||
# A second run should not raise and should not bump the version.
|
# A second run should not raise and should not bump the version.
|
||||||
db_migrate.run(db.engine())
|
db_migrate.run(db.engine())
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v2 == 12
|
assert v2 == 14
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Audit log entries record the acting user_id.
|
||||||
|
|
||||||
|
Schema: the ``audit_log`` table has a ``user_id INTEGER`` column added
|
||||||
|
by migration 0011; the SQLAlchemy ``AuditLog`` model itself does not
|
||||||
|
declare it yet, so ``append_event`` cannot pass it through. These tests
|
||||||
|
fail before the model + dataclass are updated, and pass after.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.audit_log import AuditEvent, append_event
|
||||||
|
from cyclone.db import AuditLog, SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_accepts_user_id_kwarg():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
append_event(
|
||||||
|
db,
|
||||||
|
AuditEvent(
|
||||||
|
event_type="test",
|
||||||
|
entity_type="x",
|
||||||
|
entity_id="y",
|
||||||
|
user_id=42,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
row = db.execute(select(AuditLog)).scalars().one()
|
||||||
|
assert row.user_id == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_without_user_id_is_null():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
append_event(
|
||||||
|
db,
|
||||||
|
AuditEvent(
|
||||||
|
event_type="test",
|
||||||
|
entity_type="x",
|
||||||
|
entity_id="y",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
row = db.execute(select(AuditLog)).scalars().one()
|
||||||
|
assert row.user_id is None
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Admin-only user management endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-admin tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client():
|
||||||
|
client = TestClient(app)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="root", password="rootpassword1", role="admin")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "root", "password": "rootpassword1"},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_client():
|
||||||
|
client = TestClient(app)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="plain", password="plainpassword1", role="user")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "plain", "password": "plainpassword1"},
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_as_admin(admin_client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="alice", password="hunter2hunter2", role="user")
|
||||||
|
resp = admin_client.get("/api/admin/users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
usernames = {u["username"] for u in body}
|
||||||
|
assert {"root", "alice"} <= usernames
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_as_nonadmin_returns_403(user_client):
|
||||||
|
resp = user_client.get("/api/admin/users")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_as_admin(admin_client):
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["username"] == "newbie"
|
||||||
|
assert resp.json()["role"] == "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_invalid_role(admin_client):
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_role_and_password(admin_client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="subject", password="hunter2hunter2", role="viewer")
|
||||||
|
resp = admin_client.patch(
|
||||||
|
f"/api/admin/users/{u.id}",
|
||||||
|
json={"role": "user", "password": "newpassword1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_demote_self(admin_client):
|
||||||
|
me = admin_client.get("/api/auth/me").json()
|
||||||
|
resp = admin_client.patch(
|
||||||
|
f"/api/admin/users/{me['id']}",
|
||||||
|
json={"role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Bootstrap admin user on backend startup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.auth import bootstrap, users
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# Reset the bootstrap-side AUTH_DISABLED flag so tests don't leak state
|
||||||
|
# into each other. The conftest fixture flips this back to True at the
|
||||||
|
# start of every test, but bootstrap.run() mutates this module-level
|
||||||
|
# value, so we restore it here too.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
# conftest sets CYCLONE_AUTH_DISABLED=1 at module import. Drop it
|
||||||
|
# by default so tests exercise the real bootstrap path; the
|
||||||
|
# AUTH_DISABLED-specific test re-sets it explicitly.
|
||||||
|
monkeypatch.delenv("CYCLONE_AUTH_DISABLED", raising=False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1")
|
||||||
|
bootstrap.run()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one()
|
||||||
|
assert u.role == Role.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_noop_when_users_exist(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="existing", password="hunter2hunter2", role="admin")
|
||||||
|
bootstrap.run()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
all_users = db.execute(select(User)).scalars().all()
|
||||||
|
usernames = {u.username for u in all_users}
|
||||||
|
assert usernames == {"existing"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_refuses_to_run_without_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"):
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_rejects_short_password(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short")
|
||||||
|
with pytest.raises(RuntimeError, match="12 characters"):
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_skips_when_auth_disabled(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_AUTH_DISABLED", "1")
|
||||||
|
# Even without env vars, bootstrap should NOT raise when AUTH_DISABLED=1.
|
||||||
|
bootstrap.run()
|
||||||
|
# And it must flip the deps flag so the API skips auth checks.
|
||||||
|
from cyclone.auth import deps as _deps
|
||||||
|
assert _deps.AUTH_DISABLED is True
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Login rate limit: 5 fails / 5 min per username."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import rate_limit, users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-login-rate-limit tests need the real
|
||||||
|
# auth path exercised (the rate limiter sits in front of /login),
|
||||||
|
# so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
# Reset the in-memory rate-limit counter so a previous test's 5
|
||||||
|
# failures don't poison this test. The plan recipe calls this out.
|
||||||
|
rate_limit.reset("victim")
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
rate_limit.reset("victim")
|
||||||
|
|
||||||
|
|
||||||
|
def test_5_fails_then_429():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="victim", password="hunter2hunter2", role="user")
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th should be 429.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert "Retry-After" in r.headers
|
||||||
|
# And the module-level helper confirms we're now over the threshold.
|
||||||
|
assert rate_limit.check("victim") > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_login_resets_counter():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="victim", password="hunter2hunter2", role="user")
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(4):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# Successful login — should reset the counter.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Counter reset — 5 more fails allowed.
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th is now throttled.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""API tests for /api/auth/login, /api/auth/logout, /api/auth/me."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-route tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_admin(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="admin", password="adminpassword1", role="admin")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success_returns_user_and_cookie(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="alice", password="hunter2hunter2", role="user")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "alice", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["username"] == "alice"
|
||||||
|
assert body["role"] == "user"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
assert "cyclone_session" in resp.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_bad_password_returns_401(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="bob", password="hunter2hunter2", role="user")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "bob", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["error"] == "invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_user_returns_401(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "ghost", "password": "whatever"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["error"] == "invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_disabled_user_returns_403(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="carol", password="hunter2hunter2", role="user")
|
||||||
|
users.disable(db, u.id)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "carol", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.json()["error"] == "account_disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_session_and_cookie(seeded_admin):
|
||||||
|
login = seeded_admin.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "adminpassword1"},
|
||||||
|
)
|
||||||
|
cookie = login.cookies.get("cyclone_session")
|
||||||
|
assert cookie
|
||||||
|
resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie})
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(seeded_admin):
|
||||||
|
login = seeded_admin.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "adminpassword1"},
|
||||||
|
)
|
||||||
|
cookie = login.cookies.get("cyclone_session")
|
||||||
|
resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_without_cookie_returns_401(seeded_admin):
|
||||||
|
resp = seeded_admin.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.auth import sessions, users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-sessions tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
return users.create(db, username="sessuser", password="hunter2hunter2", role="user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_returns_id_and_session():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, sess = sessions.create(db, user_id=user.id)
|
||||||
|
assert len(sid) >= 32
|
||||||
|
assert sess.user_id == user.id
|
||||||
|
assert sess.expires_at > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_session_for_active():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
got = sessions.get_valid(db, sid)
|
||||||
|
assert got is not None
|
||||||
|
assert got.user_id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_none_for_missing():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, "does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_none_for_expired():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sess = sessions.get_valid(db, sid)
|
||||||
|
sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_removes_session():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
sessions.delete(db, sid)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_touch_extends_expiry():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, sess = sessions.create(db, user_id=user.id)
|
||||||
|
original_expiry = sess.expires_at
|
||||||
|
sessions.touch(db, sid)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = sessions.get_valid(db, sid)
|
||||||
|
assert refreshed.expires_at >= original_expiry
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_users(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-users tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_returns_bcrypt():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert h.startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_produces_unique_salts():
|
||||||
|
a = users.hash_password("same-password")
|
||||||
|
b = users.hash_password("same-password")
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_correct():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert users.verify_password("hunter2hunter2", h) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_incorrect():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert users.verify_password("WRONG", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_persists_with_hashed_password():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="alice", password="hunter2hunter2", role="admin")
|
||||||
|
assert u.id is not None
|
||||||
|
assert u.username == "alice"
|
||||||
|
assert u.role == "admin"
|
||||||
|
assert u.disabled_at is None
|
||||||
|
assert u.password_hash != "hunter2hunter2"
|
||||||
|
assert u.password_hash.startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_duplicate_username():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="bob", password="hunter2hunter2", role="user")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
users.create(db, username="bob", password="anotherone", role="user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_username_returns_user():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="carol", password="hunter2hunter2", role="viewer")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "carol")
|
||||||
|
assert u is not None
|
||||||
|
assert u.username == "carol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_username_returns_none_for_missing():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert users.get_by_username(db, "ghost") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_user_sets_disabled_at():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="dave", password="hunter2hunter2", role="user")
|
||||||
|
users.disable(db, u.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = users.get_by_username(db, "dave")
|
||||||
|
assert refreshed.disabled_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_public_shape_omits_password_hash():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="eve", password="hunter2hunter2", role="admin")
|
||||||
|
shape = users.to_public(u)
|
||||||
|
assert "password_hash" not in shape
|
||||||
|
assert shape["username"] == "eve"
|
||||||
|
assert shape["role"] == "admin"
|
||||||
|
assert "id" in shape and "createdAt" in shape
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_password_actually_rehashes_and_verifies():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="frank", password="hunter2hunter2", role="user")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.update_password(db, u.id, "newpassword1")
|
||||||
|
# Old password no longer verifies.
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = users.get_by_username(db, "frank")
|
||||||
|
assert not users.verify_password("hunter2hunter2", refreshed.password_hash)
|
||||||
|
assert users.verify_password("newpassword1", refreshed.password_hash)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.
|
||||||
|
|
||||||
|
Uses Click's CliRunner (matches the existing parse-837/parse-835 CLI tests).
|
||||||
|
The full CLI group lives in ``cyclone.cli.main`` — we exercise the
|
||||||
|
``users`` subgroup through the top-level ``main`` so the wiring is real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.cli import main as cli_main
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "create", "cli-user",
|
||||||
|
"--role", "viewer",
|
||||||
|
"--password", "clipassword1",
|
||||||
|
],
|
||||||
|
input="", # don't prompt
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "cli-user")
|
||||||
|
assert u is not None
|
||||||
|
assert u.role == "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="listed", password="hunter2hunter2", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "list"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "listed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_user_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="todie", password="hunter2hunter2", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "disable", "todie"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "todie")
|
||||||
|
assert u.disabled_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_password_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="pwchange", password="oldpassword1", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "reset-password", "pwchange",
|
||||||
|
"--password", "newpassword1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "pwchange")
|
||||||
|
assert users.verify_password("newpassword1", u.password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_role_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="promote", password="hunter2hunter2", role="viewer")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
["users", "set-role", "promote", "--role", "admin"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "promote")
|
||||||
|
assert u.role == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_short_password():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "create", "weak",
|
||||||
|
"--role", "viewer",
|
||||||
|
"--password", "short",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Click surfaces validation failures with a non-zero exit code.
|
||||||
|
assert result.exit_code != 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
rows = db.execute(select(User).where(User.username == "weak")).scalars().all()
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_unknown_user_exits_nonzero():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "disable", "ghost"])
|
||||||
|
assert result.exit_code != 0, result.output
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
|
||||||
|
|
||||||
|
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
|
||||||
|
every test module that does NOT start with ``test_auth`` — this module
|
||||||
|
deliberately is NOT named ``test_auth_*`` so it inherits the default
|
||||||
|
disabled posture... wait, that's the opposite of what we want.
|
||||||
|
|
||||||
|
This module is named ``test_existing_endpoints_require_auth`` so the
|
||||||
|
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
|
||||||
|
bypassing the auth check entirely. To actually verify the gate, this
|
||||||
|
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
|
||||||
|
*just for this test*, then restores it afterwards. This way the
|
||||||
|
rest of the suite still sees the disabled posture and existing
|
||||||
|
tests keep passing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
|
||||||
|
original = AUTH_DISABLED
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
try:
|
||||||
|
yield TestClient(app)
|
||||||
|
finally:
|
||||||
|
_auth_deps.AUTH_DISABLED = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path", [
|
||||||
|
("GET", "/api/claims"),
|
||||||
|
("GET", "/api/remittances"),
|
||||||
|
("GET", "/api/providers"),
|
||||||
|
("GET", "/api/batches"),
|
||||||
|
("GET", "/api/payers/p1/summary"),
|
||||||
|
("GET", "/api/activity"),
|
||||||
|
])
|
||||||
|
def test_existing_get_endpoints_require_auth(client, method, path):
|
||||||
|
resp = client.request(method, path)
|
||||||
|
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client):
|
||||||
|
"""``/api/health`` is the public healthcheck and must remain reachable."""
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Tests that ``CycloneStore.iter_claims`` populates ``receivedAmount``
|
||||||
|
from the matched ``Remittance.total_paid``.
|
||||||
|
|
||||||
|
Before SP_Auth follow-up: every claim came back with
|
||||||
|
``receivedAmount: 0.0`` regardless of whether it had been paired with
|
||||||
|
a paid remittance, so the Dashboard's "Received" KPI was always $0
|
||||||
|
even when claims had been paid and reconciled.
|
||||||
|
|
||||||
|
The fix: ``iter_claims`` bulk-loads ``Remittance.total_paid`` for every
|
||||||
|
matched claim id in the result set (single SQL, no N+1) and stamps
|
||||||
|
the sum onto each claim dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance
|
||||||
|
from cyclone.store import store as global_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
|
db._reset_for_tests()
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_batch(s, batch_id: str) -> None:
|
||||||
|
s.add(Batch(
|
||||||
|
id=batch_id,
|
||||||
|
kind="837p",
|
||||||
|
input_filename="seed.edi",
|
||||||
|
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
|
||||||
|
totals_json={"total_claims": 3},
|
||||||
|
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||||
|
raw_result_json={"_": "stub"},
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_claim(s, claim_id: str, batch_id: str, *, matched_remit_id: str | None = None) -> None:
|
||||||
|
s.add(Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=claim_id,
|
||||||
|
service_date_from=date(2026, 6, 1),
|
||||||
|
service_date_to=date(2026, 6, 1),
|
||||||
|
charge_amount=Decimal("200.00"),
|
||||||
|
provider_npi="1234567890",
|
||||||
|
payer_id="SKCO0",
|
||||||
|
state=ClaimState.PAID,
|
||||||
|
matched_remittance_id=matched_remit_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_remit(s, remit_id: str, batch_id: str, *, total_paid: Decimal) -> None:
|
||||||
|
s.add(Remittance(
|
||||||
|
id=remit_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
payer_claim_control_number=remit_id,
|
||||||
|
claim_id=None,
|
||||||
|
status_code="1",
|
||||||
|
total_charge=Decimal("200.00"),
|
||||||
|
total_paid=total_paid,
|
||||||
|
adjustment_amount=Decimal("0"),
|
||||||
|
received_at=datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_populates_received_amount_from_matched_remittance():
|
||||||
|
"""A matched claim should reflect its remittance's ``total_paid``."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_remit(s, "r1", "b1", total_paid=Decimal("180.00"))
|
||||||
|
_make_claim(s, "CLM-A", "b1", matched_remit_id="r1")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-A"]["receivedAmount"] == pytest.approx(180.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_unmatched_claim_has_zero_received():
|
||||||
|
"""Claims with no matched remittance still get 0.0, not stale data."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_claim(s, "CLM-U", "b1", matched_remit_id=None)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-U"]["receivedAmount"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_handles_orphan_match_fk():
|
||||||
|
"""A claim with a stale ``matched_remittance_id`` whose remittance row
|
||||||
|
was deleted should default to 0.0 rather than blow up."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_claim(s, "CLM-ORPHAN", "b1", matched_remit_id="r-deleted")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
# No remittance row exists — the FK is dangling, which can happen
|
||||||
|
# if the remittance was deleted between match and now.
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-ORPHAN"]["receivedAmount"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_bulk_loads_multiple_matches_in_one_pass():
|
||||||
|
"""All matched claims in a page reflect their distinct remittance
|
||||||
|
totals — the bulk load must aggregate per remittance id."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_remit(s, "r1", "b1", total_paid=Decimal("120.00"))
|
||||||
|
_make_remit(s, "r2", "b1", total_paid=Decimal("175.50"))
|
||||||
|
_make_remit(s, "r3", "b1", total_paid=Decimal("0"))
|
||||||
|
_make_claim(s, "CLM-1", "b1", matched_remit_id="r1")
|
||||||
|
_make_claim(s, "CLM-2", "b1", matched_remit_id="r2")
|
||||||
|
_make_claim(s, "CLM-3", "b1", matched_remit_id="r3")
|
||||||
|
_make_claim(s, "CLM-4", "b1", matched_remit_id=None)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-1"]["receivedAmount"] == pytest.approx(120.00)
|
||||||
|
assert by_id["CLM-2"]["receivedAmount"] == pytest.approx(175.50)
|
||||||
|
assert by_id["CLM-3"]["receivedAmount"] == pytest.approx(0.0)
|
||||||
|
assert by_id["CLM-4"]["receivedAmount"] == 0.0
|
||||||
Generated
+47
-64
@@ -44,72 +44,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "5.0.0"
|
version = "4.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -377,9 +326,12 @@ name = "cyclone"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "keyring" },
|
{ name = "keyring" },
|
||||||
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
@@ -393,6 +345,7 @@ dev = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-randomly" },
|
||||||
]
|
]
|
||||||
sftp = [
|
sftp = [
|
||||||
{ name = "paramiko" },
|
{ name = "paramiko" },
|
||||||
@@ -403,15 +356,19 @@ sqlcipher = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = "<4.1" },
|
||||||
{ name = "click", specifier = ">=8.1,<9" },
|
{ name = "click", specifier = ">=8.1,<9" },
|
||||||
|
{ name = "cryptography", specifier = ">=49.0,<50" },
|
||||||
{ name = "fastapi", specifier = ">=0.110,<1" },
|
{ name = "fastapi", specifier = ">=0.110,<1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
||||||
{ name = "keyring", specifier = ">=25.0,<26" },
|
{ name = "keyring", specifier = ">=25.0,<26" },
|
||||||
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
||||||
|
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||||
{ name = "pydantic", specifier = ">=2.6,<3" },
|
{ name = "pydantic", specifier = ">=2.6,<3" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
|
||||||
|
{ name = "pytest-randomly", marker = "extra == 'dev'", specifier = ">=4.1" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
|
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
|
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
|
||||||
@@ -714,6 +671,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "passlib"
|
||||||
|
version = "1.7.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
bcrypt = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -935,6 +906,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-randomly"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Cyclone — local docker-compose stack.
|
||||||
|
#
|
||||||
|
# Two services on a user-defined network:
|
||||||
|
#
|
||||||
|
# frontend (nginx) — published on http://127.0.0.1:8080
|
||||||
|
# serves the built SPA + reverse-proxies /api/* to backend
|
||||||
|
# backend (uvicorn) — NOT published externally by default; reachable from
|
||||||
|
# the frontend over the compose network on backend:8000
|
||||||
|
# (override `ports:` below to expose it for curl/debug)
|
||||||
|
#
|
||||||
|
# Persistent state:
|
||||||
|
# - `cyclone-data` named volume mounted at /data in the backend holds
|
||||||
|
# the SQLite database file. Survives `docker compose down`; only
|
||||||
|
# `docker compose down -v` wipes it.
|
||||||
|
# - `config/payers.yaml` is baked into the backend image at build time.
|
||||||
|
# To edit payer config, change the YAML, rebuild the backend image,
|
||||||
|
# and `POST /api/admin/reload-config` (or just restart the container).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose up -d --build # start (or rebuild + restart)
|
||||||
|
# docker compose logs -f # tail both services
|
||||||
|
# docker compose restart backend # bounce the backend (e.g. after crash)
|
||||||
|
# docker compose down # stop containers, KEEP the data volume
|
||||||
|
# docker compose down -v # stop AND wipe the data volume
|
||||||
|
|
||||||
|
name: cyclone
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
image: cyclone-backend:local
|
||||||
|
container_name: cyclone-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Bind on all interfaces inside the container (required — 127.0.0.1
|
||||||
|
# would only be reachable from inside the container itself).
|
||||||
|
CYCLONE_HOST: "0.0.0.0"
|
||||||
|
CYCLONE_PORT: "8000"
|
||||||
|
CYCLONE_RELOAD: "0"
|
||||||
|
# Absolute path inside the container; the named volume mounts at /data.
|
||||||
|
CYCLONE_DB_URL: "sqlite:////data/cyclone.db"
|
||||||
|
# Bootstrap admin (required on first boot unless at least one user
|
||||||
|
# already exists). The ${VAR:?msg} syntax makes docker-compose refuse
|
||||||
|
# to start with a clear error if the env var isn't set in the host
|
||||||
|
# environment.
|
||||||
|
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?CYCLONE_ADMIN_USERNAME is required on first boot}
|
||||||
|
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?CYCLONE_ADMIN_PASSWORD is required on first boot (min 12 chars)}
|
||||||
|
volumes:
|
||||||
|
- cyclone-data:/data
|
||||||
|
# The healthcheck in the Dockerfile hits /api/health. The frontend
|
||||||
|
# depends_on `service_healthy` so it won't accept traffic until the
|
||||||
|
# backend is responsive.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/api/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
# No `ports:` — the frontend reaches the backend over the compose
|
||||||
|
# network. Uncomment the next line to expose the API directly for
|
||||||
|
# `curl http://localhost:8000/api/...` from the host.
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:8000:8000"
|
||||||
|
networks:
|
||||||
|
- cyclone-net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
image: cyclone-frontend:local
|
||||||
|
container_name: cyclone-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
# Bind address defaults to 0.0.0.0 (reachable from the LAN). Set
|
||||||
|
# CYCLONE_BIND_ADDRESS=127.0.0.1 to tighten to loopback-only and
|
||||||
|
# match the standalone install's local-only posture.
|
||||||
|
# Port defaults to 8081 to dodge the common clash on 8080; override
|
||||||
|
# with CYCLONE_WEB_PORT=... (see README).
|
||||||
|
- "${CYCLONE_BIND_ADDRESS:-0.0.0.0}:${CYCLONE_WEB_PORT:-8081}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- cyclone-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cyclone-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cyclone-data:
|
||||||
|
name: cyclone-data
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
|||||||
|
# Cyclone Auth (admin / user / viewer) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-22
|
||||||
|
**Status:** Draft (pending user review)
|
||||||
|
**Scope:** Adds username/password authentication and three predefined roles (admin, user, viewer) to the existing Cyclone FastAPI backend and React frontend. Browser-based login form, server-side SQLite sessions, HttpOnly cookie, role-gated endpoints. Single-machine deployment; no external IdP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Cyclone currently has no authentication. The README states the system is "local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. Built for one operator, one machine." That was true for the original single-operator design, but the system is now being prepared for production deployment where multiple humans will share the box (a clearinghouse operator, billing staff, and read-only auditors).
|
||||||
|
|
||||||
|
This sub-project adds the minimum viable role-based access control:
|
||||||
|
- A `/login` page that posts username + password to the backend.
|
||||||
|
- Server-side sessions in SQLite, keyed by an HttpOnly cookie.
|
||||||
|
- Three predefined roles — `admin`, `user`, `viewer` — with a static permission matrix.
|
||||||
|
- An admin-only user-management surface so the first admin can create other accounts.
|
||||||
|
- All existing endpoints get a `current_user` dependency; write-affording endpoints get a `require_role` gate.
|
||||||
|
|
||||||
|
After this, an operator can:
|
||||||
|
1. Start the stack with `CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=...` (or use a CLI command) so the first admin account is created on first boot.
|
||||||
|
2. Open `http://localhost:8081/`, get redirected to `/login`, sign in.
|
||||||
|
3. Browse the Dashboard, Claims, Remittances, etc. as a `user` or `viewer`.
|
||||||
|
4. As `admin`, visit a new Users page to create accounts, change roles, disable users, and reset passwords.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **Authenticate every API request.** Every existing endpoint under `/api/*` returns 401 unless a valid session cookie is present. The only unauthenticated paths are `/api/healthz` (declared before any auth dependency in `cyclone.api`) and `POST /api/auth/login` itself.
|
||||||
|
2. **Three predefined roles with a static permission matrix.** `admin` = everything including user management. `user` = read + write on claims/remits/batches/reconciliation + uploads. `viewer` = read-only — no uploads, no state changes.
|
||||||
|
3. **Server-side sessions in SQLite.** New `users` and `sessions` tables. 24-hour sliding expiry. HttpOnly cookie named `cyclone_session`.
|
||||||
|
4. **Bootstrap the first admin.** If `users` table is empty on startup, read `CYCLONE_ADMIN_USERNAME` / `CYCLONE_ADMIN_PASSWORD` env vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message.
|
||||||
|
5. **Frontend `/login` route + auth context.** `AuthProvider` loads `/api/auth/me` on mount, redirects to `/login` on 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.".
|
||||||
|
6. **Disable write-affording UI for `viewer`.** Upload, parse, resubmit, acknowledge, reconcile buttons render disabled with a tooltip when the role lacks permission. Server still gates as the source of truth — disabling the UI is a UX nicety.
|
||||||
|
7. **Audit log includes the acting user.** The existing `audit_log` table (SP11) gets a `user_id` column on a new migration; entries record the user that triggered each event.
|
||||||
|
8. **CLI command for user management.** `python -m cyclone users create <username> --role admin --password ...` works as an alternative to the admin UI for ops.
|
||||||
|
|
||||||
|
## 3. Non-goals (this sub-project)
|
||||||
|
|
||||||
|
- **LDAP / SAML / OIDC.** No external identity providers. Auth is local-only — credentials live in the SQLite `users` table.
|
||||||
|
- **Password reset emails / "forgot password" flow.** Admins reset passwords via the admin UI or CLI. There is no self-service flow.
|
||||||
|
- **Multi-factor authentication (MFA / TOTP).** Username + password is the only factor.
|
||||||
|
- **Per-resource ACLs / per-claim visibility.** The role applies globally. There is no concept of "this user can only see claims for provider X".
|
||||||
|
- **Account lockout after N failed attempts.** We rate-limit per username (5 fails per 5 min) but never permanently lock the account.
|
||||||
|
- **Cross-device session sync / "see my active sessions" UI.** Sessions are opaque cookie values; the admin UI shows the user list, not their sessions.
|
||||||
|
- **Branding / SSO.** Out of scope.
|
||||||
|
|
||||||
|
## 4. Stack
|
||||||
|
|
||||||
|
**Backend additions:**
|
||||||
|
- New module `cyclone.auth` (`users`, `sessions`, `permissions`, `routes`, `admin`, `deps`, `rate_limit`).
|
||||||
|
- `passlib[bcrypt]` for password hashing (industry standard, slow on purpose).
|
||||||
|
- `secrets.token_urlsafe(32)` for session IDs (256 bits of entropy).
|
||||||
|
- SQLAlchemy ORM (already in use) — new `User` and `Session` models on the same `cyclone.db.Base`.
|
||||||
|
- Migration `0015_users_and_sessions.py` creates both tables.
|
||||||
|
- FastAPI dependency injection for `get_current_user` and `require_role`.
|
||||||
|
|
||||||
|
**Frontend additions:**
|
||||||
|
- New `src/auth/` module: `AuthProvider` context, `useAuth` hook, `RoleGate` component, fetch wrapper that handles 401.
|
||||||
|
- New `/login` page (`src/pages/Login.tsx`).
|
||||||
|
- No new build tools; the existing Vite + React + react-query stack stays.
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- nginx `proxy_cookie_path /api/ /;` so the session cookie path survives the frontend → backend reverse proxy.
|
||||||
|
- `docker-compose.yml` adds `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` env vars to the backend service.
|
||||||
|
- Backend image installs `passlib[bcrypt]`.
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ ┌──────────────────────────────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ React SPA │ │ Cookie jar │ │
|
||||||
|
│ │ ┌────────────────────────────────────┐ │ │ cyclone_session=<id> │ │
|
||||||
|
│ │ │ <AuthProvider> │ │ │ HttpOnly, Lax, │ │
|
||||||
|
│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │
|
||||||
|
│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │
|
||||||
|
│ │ │ </AuthProvider> │ │ │
|
||||||
|
│ │ │ ┌─────────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ <RoleGate allow=...> │ │ │ │
|
||||||
|
│ │ │ │ disables write buttons │ │ │ │
|
||||||
|
│ │ │ │ for users without role │ │ │ │
|
||||||
|
│ │ │ └─────────────────────────────┘ │ │ │
|
||||||
|
│ │ └────────────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│ HTTPS (or HTTP behind LAN)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ nginx (frontend container) │
|
||||||
|
│ - serves SPA static bundle │
|
||||||
|
│ - location /api/* → proxy_pass http://cyclone-backend:8000 │
|
||||||
|
│ - proxy_cookie_path /api/ /; (rewrites cookie path) │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI (backend container) │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ cyclone.auth.routes POST /api/auth/login, logout │ │
|
||||||
|
│ │ GET /api/auth/me │ │
|
||||||
|
│ │ cyclone.auth.admin CRUD /api/admin/users │ │
|
||||||
|
│ │ cyclone.api (existing) every endpoint gains │ │
|
||||||
|
│ │ Depends(get_current_user) │ │
|
||||||
|
│ │ sensitive endpoints gain │ │
|
||||||
|
│ │ Depends(require_role("admin")) │ │
|
||||||
|
│ ├──────────────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ cyclone.auth.deps get_current_user, require_role │ │
|
||||||
|
│ │ cyclone.auth.sessions create/validate/expire │ │
|
||||||
|
│ │ cyclone.auth.users create/get/update/disable (bcrypt) │ │
|
||||||
|
│ │ cyclone.auth.permissions Role enum + matrix │ │
|
||||||
|
│ │ cyclone.auth.rate_limit in-memory 5-fail-per-5-min per username│ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ cyclone.db (SQLAlchemy) │ │
|
||||||
|
│ │ users(id, username UNIQUE, password_hash, role, │ │
|
||||||
|
│ │ created_at, disabled_at) │ │
|
||||||
|
│ │ sessions(id PK, user_id FK, expires_at, created_at) │ │
|
||||||
|
│ │ audit_log (existing — gets user_id column via 0016) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flow on login:**
|
||||||
|
1. User opens `/login` (the SPA redirects there on first visit because `/api/auth/me` returned 401).
|
||||||
|
2. User submits `{username, password}` → `POST /api/auth/login`.
|
||||||
|
3. Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with `Retry-After`.
|
||||||
|
4. Backend looks up user by username. If not found OR `bcrypt.verify(password, password_hash)` fails, increment the rate-limit counter and return 401 `{error: "invalid_credentials"}`. The error message is generic so it doesn't leak whether the username exists.
|
||||||
|
5. If user.disabled_at is not None, return 403 `{error: "account_disabled"}`.
|
||||||
|
6. Create a `sessions` row: `id = secrets.token_urlsafe(32)`, `user_id`, `expires_at = now() + 24h`.
|
||||||
|
7. Set `Set-Cookie: cyclone_session=<id>; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400` (also `Secure` when behind HTTPS — detected via `request.url.scheme` or a `BEHIND_HTTPS=1` env var).
|
||||||
|
8. Return `200 {id, username, role, createdAt}`.
|
||||||
|
|
||||||
|
**Data flow on a normal request:**
|
||||||
|
1. Browser sends request with `Cookie: cyclone_session=<id>`.
|
||||||
|
2. FastAPI middleware / dependency reads the cookie, looks up `sessions` row by `id`.
|
||||||
|
3. If missing, expired (`expires_at < now`), or user is disabled → raise `HTTPException(401, "session_expired")`.
|
||||||
|
4. Otherwise, attach `User` to `request.state.user`. The `get_current_user` dependency returns it.
|
||||||
|
5. For endpoints with `Depends(require_role("admin"))`, check `user.role == "admin"`; else raise `HTTPException(403, "forbidden")`.
|
||||||
|
|
||||||
|
**Data flow on logout:**
|
||||||
|
1. SPA sends `POST /api/auth/logout`.
|
||||||
|
2. Backend deletes the `sessions` row matching the cookie's id.
|
||||||
|
3. Backend sets `Set-Cookie: cyclone_session=; Max-Age=0` to clear the cookie.
|
||||||
|
4. SPA's `AuthProvider` clears its state and navigates to `/login`.
|
||||||
|
|
||||||
|
**Sliding expiry:**
|
||||||
|
Every successful authenticated request refreshes `sessions.expires_at = now() + 24h` (cheap UPDATE) **and** re-emits the `Set-Cookie` header with a fresh `Max-Age=86400`. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request.
|
||||||
|
|
||||||
|
## 6. Backend changes
|
||||||
|
|
||||||
|
### 6.1 New module `backend/src/cyclone/auth/`
|
||||||
|
|
||||||
|
```
|
||||||
|
auth/
|
||||||
|
├── __init__.py # re-exports the public API
|
||||||
|
├── users.py # User model, CRUD, bcrypt hashing
|
||||||
|
├── sessions.py # Session model, create/validate/expire
|
||||||
|
├── permissions.py # Role enum, permission matrix
|
||||||
|
├── deps.py # get_current_user, require_role
|
||||||
|
├── routes.py # /api/auth/login, logout, me
|
||||||
|
├── admin.py # /api/admin/users/* (admin-only)
|
||||||
|
└── rate_limit.py # per-username failed-login counter
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Data model
|
||||||
|
|
||||||
|
New SQLAlchemy models in `cyclone.db`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer"
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration `0015_users_and_sessions.py` creates both tables and adds indexes on `users.username`, `sessions.expires_at`, `sessions.user_id`.
|
||||||
|
|
||||||
|
Migration `0016_audit_log_user_id.py` adds `user_id INT NULL` to the existing `audit_log` table (SP11's hash-chained audit log).
|
||||||
|
|
||||||
|
### 6.3 Permissions matrix
|
||||||
|
|
||||||
|
`cyclone/auth/permissions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
USER = "user"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoint path prefix → set of roles allowed.
|
||||||
|
PERMISSIONS: dict[str, set[Role]] = {
|
||||||
|
# Public paths (no auth required). Empty set = anyone, including unauthenticated.
|
||||||
|
"GET /api/healthz": set(),
|
||||||
|
"POST /api/auth/login": set(),
|
||||||
|
|
||||||
|
# Auth surface (authenticated, all roles).
|
||||||
|
"POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
|
||||||
|
# Admin-only user management.
|
||||||
|
"GET /api/admin/users": {Role.ADMIN},
|
||||||
|
"POST /api/admin/users": {Role.ADMIN},
|
||||||
|
"PATCH /api/admin/users": {Role.ADMIN},
|
||||||
|
"DELETE /api/admin/users": {Role.ADMIN},
|
||||||
|
|
||||||
|
# Read endpoints (everyone authenticated can read).
|
||||||
|
"GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only
|
||||||
|
|
||||||
|
# Write endpoints (admin + user, no viewer).
|
||||||
|
"POST /api/parse-837": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/parse-835": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/inbox": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/reconcile": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/resubmit": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/acks": {Role.ADMIN, Role.USER},
|
||||||
|
# ...every other POST/PATCH/DELETE goes here too.
|
||||||
|
|
||||||
|
# CSV export — read-only, so all roles.
|
||||||
|
"GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `require_role` dependency reads `(method, path)` from the request and looks up the allowed roles. Endpoints not in the matrix default to **deny** — fail-closed.
|
||||||
|
|
||||||
|
### 6.4 Dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
# deps.py
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> User:
|
||||||
|
sid = request.cookies.get("cyclone_session")
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
|
||||||
|
session = await sessions.get_valid(sid)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
|
||||||
|
user = await users.get(session.user_id)
|
||||||
|
if not user or user.disabled_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled")
|
||||||
|
# Sliding expiry refresh.
|
||||||
|
await sessions.touch(sid)
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*allowed: Role):
|
||||||
|
async def _dep(request: Request, user: User = Depends(get_current_user)) -> User:
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
key = f"{method} {path}"
|
||||||
|
# Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims".
|
||||||
|
allowed_roles = _lookup_permissions(key)
|
||||||
|
if user.role not in allowed_roles:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
|
||||||
|
return user
|
||||||
|
return _dep
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Endpoints
|
||||||
|
|
||||||
|
**Auth (`cyclone/auth/routes.py`):**
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/api/auth/login` | public | `{username, password}` | `200 {id, username, role, createdAt}` + Set-Cookie |
|
||||||
|
| POST | `/api/auth/logout` | session | — | `204` + cookie cleared |
|
||||||
|
| GET | `/api/auth/me` | session | — | `200 {id, username, role, createdAt}` |
|
||||||
|
|
||||||
|
**Admin (`cyclone/auth/admin.py`):**
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/api/admin/users` | admin | — | `200 [{id, username, role, createdAt, disabledAt}]` |
|
||||||
|
| POST | `/api/admin/users` | admin | `{username, password, role}` | `201 {id, username, role, createdAt}` |
|
||||||
|
| PATCH | `/api/admin/users/{id}` | admin | `{role?, password?, disabled?}` | `200 {id, username, role, createdAt, disabledAt}` |
|
||||||
|
| DELETE | `/api/admin/users/{id}` | admin | — | `204` (only if user has no authored data; otherwise 409) |
|
||||||
|
|
||||||
|
**Error shapes:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 401
|
||||||
|
{ "error": "session_expired", "detail": "Session is missing or expired." }
|
||||||
|
// 403
|
||||||
|
{ "error": "forbidden", "detail": "Your role lacks permission for this action." }
|
||||||
|
// 429
|
||||||
|
{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." }
|
||||||
|
// Login 401 (generic — never leak username existence)
|
||||||
|
{ "error": "invalid_credentials", "detail": "Username or password is incorrect." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Rate limit
|
||||||
|
|
||||||
|
In-memory dict in `rate_limit.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails
|
||||||
|
WINDOW_SECONDS = 300
|
||||||
|
MAX_FAILS = 5
|
||||||
|
|
||||||
|
def check(username: str) -> int:
|
||||||
|
"""Return Retry-After seconds, or 0 if allowed."""
|
||||||
|
now = time.monotonic()
|
||||||
|
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
|
||||||
|
if len(fails) >= MAX_FAILS:
|
||||||
|
return int(WINDOW_SECONDS - (now - fails[0]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def record_failure(username: str) -> None:
|
||||||
|
_FAILS.setdefault(username, []).append(time.monotonic())
|
||||||
|
|
||||||
|
def reset(username: str) -> None:
|
||||||
|
_FAILS.pop(username, None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-process. Resets on backend restart. Acceptable for v1.
|
||||||
|
|
||||||
|
### 6.7 Bootstrap (first admin)
|
||||||
|
|
||||||
|
In `cyclone/__main__.py`, before `uvicorn.run(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def bootstrap_admin():
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
if await db.scalar(select(func.count()).select_from(User)) > 0:
|
||||||
|
return
|
||||||
|
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
|
||||||
|
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
|
||||||
|
"CYCLONE_ADMIN_PASSWORD env vars, or run "
|
||||||
|
"`python -m cyclone users create <username> --role admin` first."
|
||||||
|
)
|
||||||
|
if len(password) < 12:
|
||||||
|
raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.")
|
||||||
|
await users.create(db, username=username, password=password, role=Role.ADMIN)
|
||||||
|
print(f"[cyclone] bootstrap admin user '{username}' created")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8 CLI command
|
||||||
|
|
||||||
|
`python -m cyclone users ...`:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m cyclone users create <username> --role {admin|user|viewer} [--password <pw>]
|
||||||
|
python -m cyclone users list
|
||||||
|
python -m cyclone users disable <username>
|
||||||
|
python -m cyclone users reset-password <username> [--password <pw>]
|
||||||
|
python -m cyclone users set-role <username> --role {admin|user|viewer}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `--password` is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require `--password` from env to avoid accidental empty-password accounts.
|
||||||
|
|
||||||
|
## 7. Frontend changes
|
||||||
|
|
||||||
|
### 7.1 New module `src/auth/`
|
||||||
|
|
||||||
|
```
|
||||||
|
auth/
|
||||||
|
├── AuthProvider.tsx # React context + reducer
|
||||||
|
├── AuthProvider.test.tsx
|
||||||
|
├── useAuth.ts # hook: {user, login, logout, status}
|
||||||
|
├── api.ts # fetch wrapper that handles 401
|
||||||
|
├── RoleGate.tsx # disables children when role not allowed
|
||||||
|
└── RoleGate.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 AuthProvider
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AuthProvider.tsx (sketch)
|
||||||
|
type Status = "loading" | "authenticated" | "unauthenticated";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
status: Status;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState & { login, logout }>(...);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [state, setState] = useState<AuthState>({ status: "loading", user: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getAuthMe()
|
||||||
|
.then(user => setState({ status: "authenticated", user }))
|
||||||
|
.catch(err => {
|
||||||
|
if (err.status === 401) setState({ status: "unauthenticated", user: null });
|
||||||
|
else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Expose login() that POSTs /api/auth/login + sets state on success.
|
||||||
|
// Expose logout() that POSTs /api/auth/logout + clears state + nav to /login.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Login page
|
||||||
|
|
||||||
|
`src/pages/Login.tsx`:
|
||||||
|
- Centered card on the existing dark background, ~400px wide.
|
||||||
|
- Username + password fields, "Sign in" button, error message slot.
|
||||||
|
- On submit: POST `/api/auth/login`, on success → `useNavigate()/<prev>` or `/`.
|
||||||
|
- On `401 invalid_credentials` → "Username or password is incorrect."
|
||||||
|
- On `403 account_disabled` → "Account is disabled. Contact your administrator."
|
||||||
|
- On `429 rate_limited` → "Too many attempts. Try again in N seconds." (N from `Retry-After`).
|
||||||
|
- The page renders *without* the sidebar — it lives outside the protected `<Layout>`.
|
||||||
|
|
||||||
|
### 7.4 API wrapper
|
||||||
|
|
||||||
|
`src/auth/api.ts` re-exports the existing `lib/api.ts` but wraps `fetch` so any 401 response:
|
||||||
|
1. Calls `POST /api/auth/logout` (best-effort, ignore failure).
|
||||||
|
2. Clears the auth context.
|
||||||
|
3. `window.location.href = "/login?next=" + encodeURIComponent(currentPath)`.
|
||||||
|
|
||||||
|
Use a hard navigation (not `<Navigate>`) so the SPA's in-memory state is wiped.
|
||||||
|
|
||||||
|
### 7.5 RoleGate
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// RoleGate.tsx
|
||||||
|
interface Props {
|
||||||
|
allow: Role[];
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode; // optional explicit "no permission" UI
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGate({ allow, children, fallback }: Props) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return null;
|
||||||
|
if (allow.includes(user.role)) return <>{children}</>;
|
||||||
|
if (fallback) return <>{fallback}</>;
|
||||||
|
return (
|
||||||
|
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
|
||||||
|
<span className="pointer-events-none opacity-50">{children}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button.
|
||||||
|
|
||||||
|
### 7.6 Sidebar
|
||||||
|
|
||||||
|
`src/components/Sidebar.tsx` — replace the hardcoded "Jordan K. / Administrator" block with `<CurrentUser />` which reads `useAuth()`. Shows the user's actual username + role.
|
||||||
|
|
||||||
|
### 7.7 Routes
|
||||||
|
|
||||||
|
`src/main.tsx`:
|
||||||
|
- `QueryClientProvider`
|
||||||
|
- `AuthProvider`
|
||||||
|
- `<BrowserRouter>` with two route groups:
|
||||||
|
- **Public:** `/login` (no `<Layout>` wrap).
|
||||||
|
- **Protected:** everything else, wrapped in a `<RequireAuth>` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `<Outlet />` or `<Navigate to="/login" />`.
|
||||||
|
|
||||||
|
### 7.8 react-query keys
|
||||||
|
|
||||||
|
`useAuth()` itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (`['claims', ...]`, etc.) are unchanged. On logout, the app does a hard reload to `/login` which wipes all react-query state.
|
||||||
|
|
||||||
|
## 8. Infrastructure
|
||||||
|
|
||||||
|
### 8.1 nginx (`frontend/nginx.conf`)
|
||||||
|
|
||||||
|
The existing nginx config already proxies `/api/*` to the backend. With cookie-based auth, the only requirement is that the cookie's `Path` matches a prefix of the request URL the browser sends. We set `Path=/api` on the cookie (matching the proxied path), and the browser sends it automatically on every `/api/*` request to the frontend nginx — no `proxy_cookie_path` rewrite needed.
|
||||||
|
|
||||||
|
We do add `proxy_set_header X-Forwarded-Proto $scheme;` so the backend can detect when it's behind HTTPS and emit the `Secure` cookie flag.
|
||||||
|
|
||||||
|
### 8.2 docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me}
|
||||||
|
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me}
|
||||||
|
# CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
A `.env.example` documents the required vars.
|
||||||
|
|
||||||
|
### 8.3 Cookie `Secure` flag
|
||||||
|
|
||||||
|
Detected at request time: if `request.url.scheme == "https"` OR `os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"`, the cookie gets `Secure`. This lets the same binary work in dev (`http://localhost`) and prod (HTTPS in front of nginx).
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
### 9.1 Backend (`backend/tests/`)
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|---|---|
|
||||||
|
| `test_auth_users.py` | bcrypt hash/verify round-trip; create/get/disable; password never returned in any response shape; username uniqueness; role must be one of the enum |
|
||||||
|
| `test_auth_sessions.py` | create/validate/expire; expired session is rejected; cookie attrs (HttpOnly, Path, Max-Age) on the login response |
|
||||||
|
| `test_auth_routes.py` | login success path returns 200 + cookie; login with bad password returns 401 with `invalid_credentials`; login with disabled user returns 403; logout deletes the session row + clears cookie; /me with valid cookie returns user; /me with no cookie returns 401 |
|
||||||
|
| `test_auth_permissions.py` | for each role × each endpoint, assert the right HTTP code (200/401/403); fail-closed: endpoints not in the matrix return 403 |
|
||||||
|
| `test_auth_admin.py` | admin can GET/POST/PATCH/DELETE users; non-admin gets 403 on every /api/admin/* path; admin cannot delete themselves (409); admin cannot demote themselves below admin (409) |
|
||||||
|
| `test_auth_bootstrap.py` | empty users + env vars → admin created; empty users + missing env vars → backend refuses to start; non-empty users → env vars ignored |
|
||||||
|
| `test_auth_login_rate_limit.py` | 5 failed logins OK, 6th returns 429 with Retry-After; counter resets after window; successful login resets the counter |
|
||||||
|
| `test_audit_log_user_id.py` | existing audit-log entries get NULL user_id (back-compat); new entries after auth lands record the acting user's id |
|
||||||
|
| `test_existing_endpoints_require_auth.py` | spot-check 10 existing endpoints (claims GET, parse-837 POST, etc.) all return 401 without a cookie |
|
||||||
|
|
||||||
|
### 9.2 Frontend (`src/**/__tests__/`)
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|---|---|
|
||||||
|
| `src/auth/AuthProvider.test.tsx` | /me on mount populates user; 401 from /me leaves user null; login() POSTs and updates state; logout() POSTs and clears state |
|
||||||
|
| `src/auth/RoleGate.test.tsx` | renders children for allowed role; renders disabled-with-tooltip for disallowed role; renders fallback when provided |
|
||||||
|
| `src/pages/Login.test.tsx` | form submits with username/password; error message on 401; redirect to `next` query param on success; rate-limit message on 429 |
|
||||||
|
| `src/lib/api.test.ts` (extended) | fetch wrapper hard-navigates to `/login` on 401; passes through on other statuses |
|
||||||
|
|
||||||
|
### 9.3 End-to-end
|
||||||
|
|
||||||
|
`/tmp/verify_auth.py` (Playwright) — login with seeded admin, verify dashboard renders, verify viewer cannot see Upload dropzone enabled, verify viewer clicking Upload sees a disabled state with tooltip.
|
||||||
|
|
||||||
|
## 10. Risk + open questions
|
||||||
|
|
||||||
|
- **Cookie path rewriting is fragile.** If a future deployment puts the API at a different prefix than `/api/`, the `proxy_cookie_path` will need to change. Documented in the README.
|
||||||
|
- **The audit-log migration (0016) is technically out of scope** for "add auth" — but the user said "I want admin, user, viewer. or something like that" and not seeing *who* did *what* in the audit log is a half-measure. Include it.
|
||||||
|
- **The CLI command on Windows** will need to handle non-tty differently from Linux/macOS. Stubbed: require `--password` from env on non-tty platforms.
|
||||||
|
- **No logout-everywhere UI.** If an admin wants to invalidate all sessions for a user, they currently have to wait for sessions to expire or wipe the `sessions` table directly. Acceptable for v1; admin can `DELETE FROM sessions WHERE user_id = ?` from `sqlite3 /data/cyclone.db` if urgent.
|
||||||
|
|
||||||
|
## 11. Rollout
|
||||||
|
|
||||||
|
- All code lands in one PR (sub-project is small enough).
|
||||||
|
- Migrations 0015 + 0016 ship together; both are backwards-compatible (additive).
|
||||||
|
- The Docker image rebuilds pick up `passlib[bcrypt]` automatically.
|
||||||
|
- `README.md` gets a new "Auth" section explaining env vars, the bootstrap admin, the CLI command, and the role matrix.
|
||||||
|
- Auth applies everywhere once enabled — both the Docker deployment (`http://localhost:8081`) and the Vite dev server (`http://localhost:5173`, which proxies `/api/*` to the backend on port 8000). Operators who want to disable auth in dev can set `CYCLONE_AUTH_DISABLED=1` env var; the bootstrap function is a no-op when this is set, and the `get_current_user` dependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset.
|
||||||
Generated
-512
@@ -669,24 +669,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -704,24 +686,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -739,24 +703,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
@@ -4919,420 +4865,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
@@ -5360,50 +4892,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/esbuild": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.28.1",
|
|
||||||
"@esbuild/android-arm": "0.28.1",
|
|
||||||
"@esbuild/android-arm64": "0.28.1",
|
|
||||||
"@esbuild/android-x64": "0.28.1",
|
|
||||||
"@esbuild/darwin-arm64": "0.28.1",
|
|
||||||
"@esbuild/darwin-x64": "0.28.1",
|
|
||||||
"@esbuild/freebsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/freebsd-x64": "0.28.1",
|
|
||||||
"@esbuild/linux-arm": "0.28.1",
|
|
||||||
"@esbuild/linux-arm64": "0.28.1",
|
|
||||||
"@esbuild/linux-ia32": "0.28.1",
|
|
||||||
"@esbuild/linux-loong64": "0.28.1",
|
|
||||||
"@esbuild/linux-mips64el": "0.28.1",
|
|
||||||
"@esbuild/linux-ppc64": "0.28.1",
|
|
||||||
"@esbuild/linux-riscv64": "0.28.1",
|
|
||||||
"@esbuild/linux-s390x": "0.28.1",
|
|
||||||
"@esbuild/linux-x64": "0.28.1",
|
|
||||||
"@esbuild/netbsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/netbsd-x64": "0.28.1",
|
|
||||||
"@esbuild/openbsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/openbsd-x64": "0.28.1",
|
|
||||||
"@esbuild/openharmony-arm64": "0.28.1",
|
|
||||||
"@esbuild/sunos-x64": "0.28.1",
|
|
||||||
"@esbuild/win32-arm64": "0.28.1",
|
|
||||||
"@esbuild/win32-ia32": "0.28.1",
|
|
||||||
"@esbuild/win32-x64": "0.28.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
|||||||
+10
-1
@@ -13,6 +13,8 @@ import { Acks } from "@/pages/Acks";
|
|||||||
import { Batches } from "@/pages/Batches";
|
import { Batches } from "@/pages/Batches";
|
||||||
import { BatchDiff } from "@/pages/BatchDiff";
|
import { BatchDiff } from "@/pages/BatchDiff";
|
||||||
import Inbox from "@/pages/Inbox";
|
import Inbox from "@/pages/Inbox";
|
||||||
|
import { Login } from "@/pages/Login";
|
||||||
|
import { RequireAuth } from "@/auth/RequireAuth";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +29,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<DrillStackProvider>
|
<DrillStackProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
{/* /login sits OUTSIDE the auth-gated Layout so an
|
||||||
|
unauthenticated operator can reach the sign-in screen. */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
{/* Every other route inherits the auth gate via RequireAuth
|
||||||
|
wrapping the Layout outlet. Authenticated operators see
|
||||||
|
the full app; unauthenticated ones are bounced back here
|
||||||
|
with `?next=<current>`. */}
|
||||||
|
<Route element={<RequireAuth><Layout /></RequireAuth>}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="claims" element={<Claims />} />
|
<Route path="claims" element={<Claims />} />
|
||||||
<Route path="remittances" element={<Remittances />} />
|
<Route path="remittances" element={<Remittances />} />
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("./api", () => ({
|
||||||
|
authApi: {
|
||||||
|
me: vi.fn(),
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { authApi } from "./api";
|
||||||
|
import { AuthProvider, useAuth } from "./AuthProvider";
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("AuthProvider", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads user on mount", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: "alice",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||||
|
expect(result.current.user?.username).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status is unauthenticated when /me fails", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("401")
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
|
||||||
|
expect(result.current.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login() updates state on success", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("401")
|
||||||
|
);
|
||||||
|
(
|
||||||
|
authApi.login as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({ id: 1, username: "alice", role: "admin" });
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.login("alice", "pw");
|
||||||
|
});
|
||||||
|
expect(result.current.user?.username).toBe("alice");
|
||||||
|
expect(result.current.status).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logout() clears state", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: "alice",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
(
|
||||||
|
authApi.logout as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
expect(result.current.user).toBeNull();
|
||||||
|
expect(result.current.status).toBe("unauthenticated");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState, useCallback, type ReactNode } from "react";
|
||||||
|
import { AuthContext, type AuthContextValue, type AuthStatus } from "./useAuth";
|
||||||
|
import { authApi } from "./api";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
// Re-export useAuth so consumers can import both the context provider and
|
||||||
|
// the hook from the same module. The hook itself lives in `./useAuth` so
|
||||||
|
// the type definitions stay tree-shakeable independent of the provider.
|
||||||
|
export { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the app and exposes the auth context. On mount it probes
|
||||||
|
* /api/auth/me to decide whether the existing `cyclone_session`
|
||||||
|
* cookie is still valid. Callers (route guard, sidebar, RoleGate,
|
||||||
|
* Login page) read the resulting `status` + `user` via `useAuth()`.
|
||||||
|
*
|
||||||
|
* - "loading" → while the probe is in flight
|
||||||
|
* - "authenticated" → probe succeeded; user populated
|
||||||
|
* - "unauthenticated"→ probe failed; user is null
|
||||||
|
*
|
||||||
|
* `login` and `logout` mutate local state directly so the UI flips
|
||||||
|
* without waiting for a second /me round-trip.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me as User);
|
||||||
|
setStatus("authenticated");
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const u = await authApi.login(username, password);
|
||||||
|
setUser(u as User);
|
||||||
|
setStatus("authenticated");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await authApi.logout().catch(() => undefined);
|
||||||
|
setUser(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthContextValue = { status, user, login, logout, refresh };
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guard. Wrap any subtree that requires a logged-in operator:
|
||||||
|
*
|
||||||
|
* <Route element={<RequireAuth><Layout/></RequireAuth>}>
|
||||||
|
* <Route path="/" element={<Dashboard />} />
|
||||||
|
* ...
|
||||||
|
* </Route>
|
||||||
|
*
|
||||||
|
* Three branches, in priority order:
|
||||||
|
*
|
||||||
|
* 1. `status === "loading"` → render a centered "Loading…"
|
||||||
|
* placeholder so we don't bounce the user to /login before the
|
||||||
|
* cookie has had a chance to prove itself via /api/auth/me.
|
||||||
|
* 2. `status === "unauthenticated"` → redirect to
|
||||||
|
* `/login?next=<current path+search>`. The Login page reads
|
||||||
|
* `next` and bounces the operator back here on success.
|
||||||
|
* 3. otherwise → render children (status is
|
||||||
|
* "authenticated").
|
||||||
|
*/
|
||||||
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { status } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`}
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("./useAuth", () => ({
|
||||||
|
useAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
import { RoleGate } from "./RoleGate";
|
||||||
|
|
||||||
|
function mockUser(role: "admin" | "user" | "viewer" | null) {
|
||||||
|
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
user: role ? { id: 1, username: "x", role } : null,
|
||||||
|
status: role ? "authenticated" : "unauthenticated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RoleGate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children when role is allowed", () => {
|
||||||
|
mockUser("admin");
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.querySelector("button")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Do thing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders disabled (via span wrap) when role is NOT allowed", () => {
|
||||||
|
mockUser("viewer");
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
const buttons = container.querySelectorAll("button");
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
// The single child element is wrapped in a span that carries the
|
||||||
|
// disabled visual signal — opacity-50 plus pointer-events-none so
|
||||||
|
// the inner control can't be clicked.
|
||||||
|
const wrapper = container.querySelector("span");
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
expect(wrapper?.className).toContain("opacity-50");
|
||||||
|
expect(wrapper?.className).toContain("pointer-events-none");
|
||||||
|
// The wrapper also carries a native `title` attribute that
|
||||||
|
// explains why the affordance is disabled.
|
||||||
|
expect(wrapper?.getAttribute("title")).toMatch(/role \(viewer\) cannot perform this action/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fallback when provided and role not allowed", () => {
|
||||||
|
mockUser("viewer");
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin"]} fallback={<span>NO</span>}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("NO");
|
||||||
|
// Fallback path does NOT wrap children — original button is absent.
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when user is null", () => {
|
||||||
|
mockUser(null);
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Do thing");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
type Role = "admin" | "user" | "viewer";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Roles allowed to interact with the gated affordance. The user's
|
||||||
|
* role must be in this list for children to render normally.
|
||||||
|
*/
|
||||||
|
allow: Role[];
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* What to render instead when the user lacks the role AND we don't
|
||||||
|
* want to show the disabled affordance at all (e.g. "contact your
|
||||||
|
* admin" hint, or just nothing).
|
||||||
|
*/
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RoleGate wraps any write affordance (parse button, resubmit action,
|
||||||
|
* "match selected", etc.) so it visibly disables itself when the
|
||||||
|
* current user's role isn't in `allow`.
|
||||||
|
*
|
||||||
|
* Behavior matrix:
|
||||||
|
*
|
||||||
|
* - user is null → render nothing (the route guard
|
||||||
|
* already redirects to /login; this
|
||||||
|
* is belt-and-braces).
|
||||||
|
* - user.role in `allow` → render children unchanged.
|
||||||
|
* - user.role NOT in `allow` and
|
||||||
|
* `fallback` is provided → render `fallback`.
|
||||||
|
* - user.role NOT in `allow` and
|
||||||
|
* no `fallback` → wrap children in a span carrying
|
||||||
|
* `opacity-50` + `pointer-events-none`
|
||||||
|
* + a `title` attribute that
|
||||||
|
* explains why. We use the native
|
||||||
|
* HTML `title` instead of a custom
|
||||||
|
* Tooltip component to keep this
|
||||||
|
* dependency-free and accessible
|
||||||
|
* by default.
|
||||||
|
*/
|
||||||
|
export function RoleGate({ allow, children, fallback }: Props) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return null;
|
||||||
|
if (allow.includes(user.role)) return <>{children}</>;
|
||||||
|
if (fallback !== undefined) return <>{fallback}</>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none opacity-50 inline-flex"
|
||||||
|
title={`Your role (${user.role}) cannot perform this action.`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
describe("auth/api fetch wrapper", () => {
|
||||||
|
let originalFetch: typeof globalThis.fetch;
|
||||||
|
let originalLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
|
originalLocation = window.location;
|
||||||
|
delete (window as any).__navCalls;
|
||||||
|
(window as any).__navCalls = [];
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
window.location = originalLocation as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login on 401 response", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({ error: "session_expired" }),
|
||||||
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
// Stub window.location.href setter to capture navigation without
|
||||||
|
// actually navigating (jsdom does not implement location.href assignment).
|
||||||
|
let href = originalLocation.href;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
...originalLocation,
|
||||||
|
get href() {
|
||||||
|
return href;
|
||||||
|
},
|
||||||
|
set href(v: string) {
|
||||||
|
(window as any).__navCalls.push(v);
|
||||||
|
href = v;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { authedFetch } = await import("./api");
|
||||||
|
await expect(authedFetch("/api/anything")).rejects.toThrow();
|
||||||
|
expect((window as any).__navCalls).toContainEqual(expect.stringContaining("/login"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT redirect on 403", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: "Forbidden",
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({ error: "forbidden" }),
|
||||||
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
...originalLocation,
|
||||||
|
set href(_: string) {
|
||||||
|
throw new Error("should not nav");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { authedFetch } = await import("./api");
|
||||||
|
await expect(authedFetch("/api/anything")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns parsed JSON on 200", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({ hello: "world" }),
|
||||||
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
const { authedFetch } = await import("./api");
|
||||||
|
const data = await authedFetch("/api/anything");
|
||||||
|
expect(data).toEqual({ hello: "world" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Auth-aware fetch wrapper.
|
||||||
|
*
|
||||||
|
* Every authenticated backend call in the SPA goes through `authedFetch`.
|
||||||
|
* Responsibilities:
|
||||||
|
*
|
||||||
|
* 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly
|
||||||
|
* cookie rides along on cross-origin XHR calls.
|
||||||
|
* 2. Tag every request with `Accept: application/json` by default so
|
||||||
|
* the FastAPI backend knows to return its structured error shape
|
||||||
|
* (`{"error": "...", "detail": "..."}`) on failures.
|
||||||
|
* 3. On a 401 from anything OTHER than the auth endpoints themselves,
|
||||||
|
* redirect to `/login?next=<current>` so the operator sees a real
|
||||||
|
* sign-in screen instead of an infinite stream of failing queries.
|
||||||
|
* 401 from `/api/auth/*` is the normal "bad password" path — let
|
||||||
|
* the caller handle it.
|
||||||
|
* 4. On any other non-2xx, parse the JSON body and throw an `ApiError`
|
||||||
|
* carrying `status`, the backend's `error` code, and the `detail`
|
||||||
|
* string. The Login page and hooks both branch on `.code`.
|
||||||
|
* 5. On 204, return `undefined` (so callers can `await` without
|
||||||
|
* blowing up on `res.json()` of an empty body).
|
||||||
|
* 6. Otherwise return the parsed JSON.
|
||||||
|
*
|
||||||
|
* `authApi` is the typed wrapper around the three auth endpoints
|
||||||
|
* (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||||
|
|
||||||
|
function joinUrl(path: string): string {
|
||||||
|
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown for any non-2xx `authedFetch` response. Carries the HTTP
|
||||||
|
* `status`, the backend's `error` code (so callers can branch on
|
||||||
|
* `err.code === "invalid_credentials"`, etc.), and the optional `detail`
|
||||||
|
* string for surfacing in toasts.
|
||||||
|
*
|
||||||
|
* Distinct from the `ApiError` in `src/lib/api.ts` — that one only
|
||||||
|
* carries `status` and is used by the existing pages; this one is the
|
||||||
|
* richer auth-aware variant that the Login page + hooks depend on.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
detail?: string;
|
||||||
|
constructor(status: number, code: string, detail?: string) {
|
||||||
|
super(detail ?? code);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
window.location.href = `/login?next=${next}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authedFetch<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new ApiError(401, "session_expired");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* no body */
|
||||||
|
}
|
||||||
|
const code = body?.error ?? "error";
|
||||||
|
const detail = body?.detail ?? res.statusText;
|
||||||
|
throw new ApiError(res.status, code, detail);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `authedFetch` but returns the response body as text instead of
|
||||||
|
* parsed JSON. Used by `serializeClaim837` (the SP8 endpoint returns
|
||||||
|
* `text/x12`, not JSON). Same 401-redirect + error-shape behavior as
|
||||||
|
* the JSON variant.
|
||||||
|
*/
|
||||||
|
export async function authedFetchText(path: string, init?: RequestInit): Promise<string> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new ApiError(401, "session_expired");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* no body */
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
body?.error ?? "error",
|
||||||
|
body?.detail ?? res.statusText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `authedFetch` but returns the raw `Response` so the caller can
|
||||||
|
* stream the body (NDJSON). Used by `parse837` / `parse835`. 401 still
|
||||||
|
* redirects; the caller is responsible for reading the body.
|
||||||
|
*/
|
||||||
|
export async function authedFetchResponse(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new ApiError(401, "session_expired");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
|
||||||
|
// they bypass the 401-redirect behavior on purpose — a 401 from
|
||||||
|
// /api/auth/login is "wrong password", not "session expired".
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const res = await fetch(joinUrl("/api/auth/login"), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
body.error ?? "error",
|
||||||
|
body.detail ?? res.statusText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
async me() {
|
||||||
|
return authedFetch("/api/auth/me");
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
// Fire-and-forget. A network error here is fine — the server has
|
||||||
|
// already cleared the cookie or the operator is signing out because
|
||||||
|
// they're about to be disconnected. Either way, the client should
|
||||||
|
// drop the user into the unauthenticated state.
|
||||||
|
await fetch(joinUrl("/api/auth/logout"), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
}).catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tri-state lifecycle for the auth surface.
|
||||||
|
*
|
||||||
|
* - "loading" → we haven't asked /api/auth/me yet, or the result
|
||||||
|
* is still in flight. Route guards render a
|
||||||
|
* placeholder so we don't bounce the user to
|
||||||
|
* /login before the cookie has had a chance to
|
||||||
|
* prove itself.
|
||||||
|
* - "authenticated" → /me returned a real User. The rest of the app
|
||||||
|
* can safely render protected surfaces.
|
||||||
|
* - "unauthenticated"→ /me failed (no cookie, expired cookie, server
|
||||||
|
* down). Route guards redirect to /login.
|
||||||
|
*/
|
||||||
|
export type AuthStatus = "loading" | "authenticated" | "unauthenticated";
|
||||||
|
|
||||||
|
export interface AuthContextValue {
|
||||||
|
status: AuthStatus;
|
||||||
|
user: User | null;
|
||||||
|
/**
|
||||||
|
* Authenticate against /api/auth/login. Throws `ApiError` on failure
|
||||||
|
* (with `code` = "invalid_credentials" | "account_disabled" |
|
||||||
|
* "rate_limited" | "error"). On success, status flips to
|
||||||
|
* "authenticated" and `user` is populated.
|
||||||
|
*/
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Hit /api/auth/logout and clear local state. Always succeeds
|
||||||
|
* locally — even if the server is unreachable, the operator is
|
||||||
|
* effectively signed out from the SPA's perspective.
|
||||||
|
*/
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Re-run the /api/auth/me probe. Useful after a profile edit on the
|
||||||
|
* admin pages so the sidebar reflects the new role without a
|
||||||
|
* full reload.
|
||||||
|
*/
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current auth context. Throws when used outside of an
|
||||||
|
* `<AuthProvider>` so consumers fail loudly during development instead
|
||||||
|
* of silently rendering the unauthenticated branch.
|
||||||
|
*/
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used inside <AuthProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
+60
-13
@@ -7,6 +7,7 @@ import {
|
|||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
Receipt,
|
Receipt,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
Upload as UploadIcon,
|
Upload as UploadIcon,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useReconciliation } from "@/hooks/useReconciliation";
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||||
@@ -188,25 +190,70 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Operator card — always at the bottom. Ringed, mono id, the
|
{/* Operator card — always at the bottom. Ringed, mono id, the
|
||||||
account role under the name. */}
|
account role under the name. The avatar initial + username +
|
||||||
|
role all come from `useAuth()` now (replaced the hardcoded
|
||||||
|
"Jordan K. / Administrator"); the Sign out button next to it
|
||||||
|
hits `authApi.logout` and drops the operator back at /login. */}
|
||||||
<div className="px-3 py-4 border-t border-sidebar-border/80">
|
<div className="px-3 py-4 border-t border-sidebar-border/80">
|
||||||
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
|
<CurrentUserCard />
|
||||||
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono">
|
|
||||||
JK
|
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
|
|
||||||
</div>
|
|
||||||
<div className="leading-tight min-w-0">
|
|
||||||
<div className="text-[13px] font-medium truncate">Jordan K.</div>
|
|
||||||
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
|
||||||
Administrator
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-of-sidebar identity card. Driven by `useAuth()` so the
|
||||||
|
* username + role update whenever the operator's session changes
|
||||||
|
* (e.g. an admin re-grants a role via the admin pages, or the
|
||||||
|
* session expires and a fresh login lands them back with a
|
||||||
|
* different username).
|
||||||
|
*/
|
||||||
|
function CurrentUserCard() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
if (!user) {
|
||||||
|
// Shouldn't happen — the route guard ensures the sidebar only
|
||||||
|
// mounts after auth is established. Render a harmless placeholder
|
||||||
|
// so the layout doesn't reflow if the context ever lags.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-md p-2 text-[12px] text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-muted/40" />
|
||||||
|
<div className="leading-tight">Signed out</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Use the first letter of the username for the avatar monogram;
|
||||||
|
// fall back to "?" if the username is somehow empty.
|
||||||
|
const initial = user.username?.[0]?.toUpperCase() ?? "?";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 group">
|
||||||
|
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono shrink-0">
|
||||||
|
{initial}
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
|
||||||
|
</div>
|
||||||
|
<div className="leading-tight min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-medium truncate">{user.username}</div>
|
||||||
|
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
||||||
|
{user.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void logout();
|
||||||
|
}}
|
||||||
|
aria-label="Sign out"
|
||||||
|
title="Sign out"
|
||||||
|
className="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring opacity-60 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
||||||
|
|||||||
+120
-158
@@ -43,10 +43,36 @@ import type {
|
|||||||
UnmatchedResponse,
|
UnmatchedResponse,
|
||||||
BatchSummary as ParserBatchSummary,
|
BatchSummary as ParserBatchSummary,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
import {
|
||||||
|
authedFetch,
|
||||||
|
authedFetchResponse,
|
||||||
|
} from "@/auth/api";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||||
|
|
||||||
export const isConfigured = BASE_URL.length > 0;
|
/**
|
||||||
|
* Whether the app should hit the live backend.
|
||||||
|
*
|
||||||
|
* Two paths look identical at runtime:
|
||||||
|
*
|
||||||
|
* - ``VITE_API_BASE_URL`` unset or empty → ``BASE_URL === ""`` and
|
||||||
|
* ``joinUrl()`` produces *relative* URLs (``/api/health`` etc.).
|
||||||
|
* Vite's dev proxy (and nginx in the Docker stack) intercept these
|
||||||
|
* and forward them to the FastAPI server, so the browser sees a
|
||||||
|
* same-origin call.
|
||||||
|
* - ``VITE_API_BASE_URL=https://api.example.com`` → absolute URLs
|
||||||
|
* that bypass the proxy and hit the backend directly (cross-origin
|
||||||
|
* CORS applies).
|
||||||
|
*
|
||||||
|
* We treat both as "configured" because the hooks above (``useClaims``
|
||||||
|
* etc.) only need to know whether to attempt a network request vs.
|
||||||
|
* fall back to the in-memory zustand store. The proxy is invisible to
|
||||||
|
* the page — it always sees a successful fetch unless the backend is
|
||||||
|
* actually down. The only way to opt out is to set the env var to the
|
||||||
|
* literal string ``"disabled"`` (a sentinel we treat as "no backend"),
|
||||||
|
* which is useful for offline Storybook-style previews.
|
||||||
|
*/
|
||||||
|
export const isConfigured = BASE_URL !== "disabled";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared types
|
// Shared types
|
||||||
@@ -167,10 +193,6 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinUrl(path: string): string {
|
|
||||||
return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readErrorBody(res: Response): Promise<string> {
|
async function readErrorBody(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
@@ -296,11 +318,18 @@ async function parse837(
|
|||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
|
|
||||||
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
||||||
const res = await fetch(joinUrl(`/api/parse-837?${params.toString()}`), {
|
// authedFetchResponse still hits fetch() (and still does the
|
||||||
|
// 401-redirect for any non-/api/auth path), but hands back the raw
|
||||||
|
// Response so we can stream NDJSON line-by-line below. For non-stream
|
||||||
|
// callers it returns the parsed JSON.
|
||||||
|
const res = await authedFetchResponse(
|
||||||
|
`/api/parse-837?${params.toString()}`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
headers: { Accept: accept },
|
headers: { Accept: accept },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
@@ -332,11 +361,17 @@ async function parse835(
|
|||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
|
|
||||||
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
||||||
const res = await fetch(joinUrl(`/api/parse-835?${params.toString()}`), {
|
// See parse837 for why this uses authedFetchResponse instead of
|
||||||
|
// authedFetch — NDJSON streaming needs the raw Response so we can
|
||||||
|
// iterate `res.body` one chunk at a time.
|
||||||
|
const res = await authedFetchResponse(
|
||||||
|
`/api/parse-835?${params.toString()}`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
headers: { Accept: accept },
|
headers: { Accept: accept },
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
@@ -362,16 +397,7 @@ async function parse999(
|
|||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
const res = await fetch(joinUrl("/api/parse-999"), {
|
const body = await authedFetch<{
|
||||||
method: "POST",
|
|
||||||
body: form,
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
ack: {
|
ack: {
|
||||||
id: number;
|
id: number;
|
||||||
accepted_count: number;
|
accepted_count: number;
|
||||||
@@ -382,7 +408,14 @@ async function parse999(
|
|||||||
raw_999_text: string;
|
raw_999_text: string;
|
||||||
};
|
};
|
||||||
parsed: unknown;
|
parsed: unknown;
|
||||||
};
|
}>("/api/parse-999", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
// Don't set Content-Type — the browser sets the multipart
|
||||||
|
// boundary when the body is FormData. authedFetch only adds a
|
||||||
|
// default Accept, which we override here.
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ack: {
|
ack: {
|
||||||
id: body.ack.id,
|
id: body.ack.id,
|
||||||
@@ -403,14 +436,14 @@ async function parse999(
|
|||||||
|
|
||||||
async function health(): Promise<HealthResponse | null> {
|
async function health(): Promise<HealthResponse | null> {
|
||||||
if (!isConfigured) return null;
|
if (!isConfigured) return null;
|
||||||
const res = await fetch(joinUrl("/api/health"));
|
// health() is best-effort — used by the side-scan ping. Don't let a
|
||||||
if (!res.ok) {
|
// 401 from authedFetch redirect the operator; just return null when
|
||||||
const detail = await readErrorBody(res);
|
// anything goes wrong.
|
||||||
throw new Error(
|
try {
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
return await authedFetch<HealthResponse>("/api/health");
|
||||||
);
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return (await res.json()) as HealthResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -423,48 +456,26 @@ async function listBatches(limit?: number): Promise<BatchSummary[]> {
|
|||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const params: Record<string, unknown> = {};
|
const params: Record<string, unknown> = {};
|
||||||
if (limit !== undefined) params.limit = limit;
|
if (limit !== undefined) params.limit = limit;
|
||||||
const res = await fetch(joinUrl(`/api/batches${qs(params)}`), {
|
const body = await authedFetch<{ items: BatchSummary[] }>(
|
||||||
headers: { Accept: "application/json" },
|
`/api/batches${qs(params)}`
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
const body = (await res.json()) as { items: BatchSummary[] };
|
|
||||||
return body.items;
|
return body.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), {
|
return authedFetch<ParseResult837 | ParseResult835>(
|
||||||
headers: { Accept: "application/json" },
|
`/api/batches/${encodeURIComponent(id)}`
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return (await res.json()) as ParseResult837 | ParseResult835;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listClaims<T = unknown>(
|
async function listClaims<T = unknown>(
|
||||||
params: ListClaimsParams
|
params: ListClaimsParams
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`),
|
`/api/claims${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -480,14 +491,31 @@ async function listClaims<T = unknown>(
|
|||||||
*/
|
*/
|
||||||
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), {
|
try {
|
||||||
headers: { Accept: "application/json" },
|
return await authedFetch<ClaimDetail>(
|
||||||
});
|
`/api/claims/${encodeURIComponent(id)}`
|
||||||
if (!res.ok) {
|
);
|
||||||
const detail = await readErrorBody(res);
|
} catch (err) {
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
// authedFetch throws the richer auth/api.ts ApiError (carries
|
||||||
|
// `status` + `code` + `detail`). The drawer code branches on
|
||||||
|
// `.status === 404` for the "claim doesn't exist" state, and
|
||||||
|
// existing callers use `instanceof ApiError` (the lib/api.ts one)
|
||||||
|
// to detect structured failures. Re-wrap into the lib/api.ts
|
||||||
|
// ApiError so both keep working unchanged.
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"status" in err &&
|
||||||
|
typeof (err as { status: unknown }).status === "number"
|
||||||
|
) {
|
||||||
|
const e = err as { status: number; code?: string; detail?: string };
|
||||||
|
throw new ApiError(
|
||||||
|
e.status,
|
||||||
|
e.detail ?? e.code ?? "error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return (await res.json()) as ClaimDetail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -509,8 +537,14 @@ async function serializeClaim837(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<{ text: string; filename: string }> {
|
): Promise<{ text: string; filename: string }> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
// authedFetchText gives us the body as a string with the same
|
||||||
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
|
// 401-redirect + error-shape behavior as authedFetch. We need the
|
||||||
|
// Response object for the Content-Disposition header, so this
|
||||||
|
// function delegates to authedFetchResponse and reads text +
|
||||||
|
// headers itself. No Accept negotiation — the endpoint always
|
||||||
|
// returns text/x12.
|
||||||
|
const res = await authedFetchResponse(
|
||||||
|
`/api/claims/${encodeURIComponent(id)}/serialize-837`
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
@@ -530,17 +564,9 @@ async function listRemittances<T = unknown>(
|
|||||||
params: ListRemittancesParams
|
params: ListRemittancesParams
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`),
|
`/api/remittances${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -549,14 +575,7 @@ async function listRemittances<T = unknown>(
|
|||||||
*/
|
*/
|
||||||
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), {
|
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -580,34 +599,16 @@ async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
|||||||
if (typeof b !== "string" || b.length === 0) {
|
if (typeof b !== "string" || b.length === 0) {
|
||||||
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
|
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
|
||||||
}
|
}
|
||||||
const res = await fetch(
|
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
|
||||||
joinUrl(
|
|
||||||
`/api/batch-diff?${qs({ a, b })}`,
|
|
||||||
),
|
|
||||||
{ headers: { Accept: "application/json" } },
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as BatchDiff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listProviders<T = unknown>(
|
async function listProviders<T = unknown>(
|
||||||
params: ListProvidersParams = {}
|
params: ListProvidersParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`),
|
`/api/providers${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -674,17 +675,9 @@ async function listActivity<T = unknown>(
|
|||||||
params: ListActivityParams = {}
|
params: ListActivityParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`),
|
`/api/activity${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -695,14 +688,7 @@ async function listActivity<T = unknown>(
|
|||||||
|
|
||||||
async function listUnmatched(): Promise<UnmatchedResponse> {
|
async function listUnmatched(): Promise<UnmatchedResponse> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/reconciliation/unmatched`), {
|
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as UnmatchedResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function matchRemit(
|
async function matchRemit(
|
||||||
@@ -710,36 +696,20 @@ async function matchRemit(
|
|||||||
remitId: string
|
remitId: string
|
||||||
): Promise<MatchResponse> {
|
): Promise<MatchResponse> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/reconciliation/match`), {
|
return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
|
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as MatchResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
|
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/reconciliation/unmatch`), {
|
return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ claim_id: claimId }),
|
body: JSON.stringify({ claim_id: claimId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as { claim: UnmatchedClaim };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -778,15 +748,12 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
|
|||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const query: Record<string, unknown> = {};
|
const query: Record<string, unknown> = {};
|
||||||
if (params.limit !== undefined) query.limit = params.limit;
|
if (params.limit !== undefined) query.limit = params.limit;
|
||||||
const res = await fetch(
|
const body = await authedFetch<{
|
||||||
joinUrl(`/api/acks${qs(query)}`),
|
items: RawAckRow[];
|
||||||
{ headers: { Accept: "application/json" } }
|
total: number;
|
||||||
);
|
returned: number;
|
||||||
if (!res.ok) {
|
has_more: boolean;
|
||||||
const detail = await readErrorBody(res);
|
}>(`/api/acks${qs(query)}`);
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { items: RawAckRow[]; total: number; returned: number; has_more: boolean };
|
|
||||||
return {
|
return {
|
||||||
items: body.items.map(mapAck),
|
items: body.items.map(mapAck),
|
||||||
total: body.total,
|
total: body.total,
|
||||||
@@ -797,14 +764,9 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
|
|||||||
|
|
||||||
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/acks/${encodeURIComponent(String(id))}`), {
|
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
|
||||||
headers: { Accept: "application/json" },
|
`/api/acks/${encodeURIComponent(String(id))}`
|
||||||
});
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
const row = (await res.json()) as RawAckRow & { raw_json: unknown };
|
|
||||||
return { ...mapAck(row), rawJson: row.raw_json };
|
return { ...mapAck(row), rawJson: row.raw_json };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { AuthProvider } from "@/auth/AuthProvider";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -18,7 +19,13 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
{/* AuthProvider must sit inside BrowserRouter (so it can use
|
||||||
|
react-router hooks) but outside the route tree (so the
|
||||||
|
`/login` route still has access to auth state for the
|
||||||
|
auto-redirect when the operator is already signed in). */}
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
+30
-6
@@ -17,12 +17,16 @@ import { AnimatedNumber } from "@/components/AnimatedNumber";
|
|||||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { eventKindToUrl } from "@/lib/event-routing";
|
import { eventKindToUrl } from "@/lib/event-routing";
|
||||||
import { useAppStore } from "@/store";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { useClaims } from "@/hooks/useClaims";
|
||||||
|
import { useProviders } from "@/hooks/useProviders";
|
||||||
|
import { useActivity } from "@/hooks/useActivity";
|
||||||
|
import type { Claim } from "@/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const MONTHS_BACK = 6;
|
const MONTHS_BACK = 6;
|
||||||
|
|
||||||
function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"]) {
|
function buildMonthly(claims: Claim[]) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const months: {
|
const months: {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -70,11 +74,27 @@ function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"])
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const claims = useAppStore((s) => s.claims);
|
// Live data: hooks fetch from /api/* when api.isConfigured; otherwise
|
||||||
const providers = useAppStore((s) => s.providers);
|
// they fall back to the in-memory store. Pulling from the hooks (not
|
||||||
const activity = useAppStore((s) => s.activity);
|
// the store directly) is what wires the Dashboard to the backend.
|
||||||
|
const claimsQuery = useClaims({ limit: 100 });
|
||||||
|
const providersQuery = useProviders();
|
||||||
|
const activityQuery = useActivity({ limit: 10 });
|
||||||
|
|
||||||
|
const claims = claimsQuery.data?.items ?? [];
|
||||||
|
const providers = providersQuery.data?.items ?? [];
|
||||||
|
const activity = activityQuery.data?.items ?? [];
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// Time-of-day greeting + live operator name from the auth context.
|
||||||
|
// Falls back to a neutral "there" while /api/auth/me is still in
|
||||||
|
// flight so we never flash "undefined" at the operator.
|
||||||
|
const auth = useAuth();
|
||||||
|
const greetingPart = (() => {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
return h < 12 ? "morning" : h < 18 ? "afternoon" : "evening";
|
||||||
|
})();
|
||||||
|
const operatorName = auth.user?.username ?? "there";
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
||||||
@@ -141,7 +161,11 @@ export function Dashboard() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`Today · ${fmt.date(new Date().toISOString())}`}
|
eyebrow={`Today · ${fmt.date(new Date().toISOString())}`}
|
||||||
title={<>Good morning, Jordan.</>}
|
title={
|
||||||
|
<>
|
||||||
|
Good {greetingPart}, {operatorName}.
|
||||||
|
</>
|
||||||
|
}
|
||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
<span className="display text-foreground">Three NPIs</span> are
|
<span className="display text-foreground">Three NPIs</span> are
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import Inbox from "./Inbox";
|
|||||||
import * as inboxApi from "@/lib/inbox-api";
|
import * as inboxApi from "@/lib/inbox-api";
|
||||||
import * as downloadModule from "@/lib/download";
|
import * as downloadModule from "@/lib/download";
|
||||||
|
|
||||||
|
// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
|
||||||
|
// dismiss) are wrapped in RoleGate, which reads the auth context to
|
||||||
|
// decide whether the current user has write permission. We don't want
|
||||||
|
// the tests to spin up the full AuthProvider + /api/auth/me probe —
|
||||||
|
// instead we stub useAuth to return an admin user so RoleGate renders
|
||||||
|
// the BulkBars synchronously on first render.
|
||||||
|
vi.mock("@/auth/useAuth", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
status: "authenticated" as const,
|
||||||
|
user: {
|
||||||
|
id: "test-admin",
|
||||||
|
username: "test-admin",
|
||||||
|
role: "admin" as const,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
||||||
// claim drilldown to /claims?claim=ID), so the render harness needs
|
// claim drilldown to /claims?claim=ID), so the render harness needs
|
||||||
// a Router context. We use a fresh QueryClient per test so the
|
// a Router context. We use a fresh QueryClient per test so the
|
||||||
|
|||||||
+14
-1
@@ -29,6 +29,7 @@ import {
|
|||||||
acknowledgePayerRejected,
|
acknowledgePayerRejected,
|
||||||
} from "@/lib/inbox-api";
|
} from "@/lib/inbox-api";
|
||||||
import { downloadBlob } from "@/lib/download";
|
import { downloadBlob } from "@/lib/download";
|
||||||
|
import { RoleGate } from "@/auth/RoleGate";
|
||||||
|
|
||||||
type LaneKey =
|
type LaneKey =
|
||||||
| "rejected"
|
| "rejected"
|
||||||
@@ -490,7 +491,14 @@ export default function Inbox() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
{/* Per-lane bulk bars. Each shows when its lane has a selection.
|
||||||
|
The three write-affordance BulkBars (rejected → resubmit,
|
||||||
|
payer_rejected → acknowledge, candidates → dismiss) are gated
|
||||||
|
by RoleGate so a viewer-role account can still SEE the rows
|
||||||
|
and select them but cannot trigger a state change. The export
|
||||||
|
buttons live inside the same BulkBar component but are
|
||||||
|
read-only, so we let them remain visible to viewers. */}
|
||||||
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="rejected"
|
lane="rejected"
|
||||||
count={selected.rejected.length}
|
count={selected.rejected.length}
|
||||||
@@ -499,6 +507,8 @@ export default function Inbox() {
|
|||||||
onDismiss={() => {}}
|
onDismiss={() => {}}
|
||||||
onExport={() => onExport("rejected")}
|
onExport={() => onExport("rejected")}
|
||||||
/>
|
/>
|
||||||
|
</RoleGate>
|
||||||
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="payer_rejected"
|
lane="payer_rejected"
|
||||||
count={selected.payer_rejected.length}
|
count={selected.payer_rejected.length}
|
||||||
@@ -507,6 +517,8 @@ export default function Inbox() {
|
|||||||
onDismiss={() => {}}
|
onDismiss={() => {}}
|
||||||
onExport={() => onExport("payer_rejected")}
|
onExport={() => onExport("payer_rejected")}
|
||||||
/>
|
/>
|
||||||
|
</RoleGate>
|
||||||
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="candidates"
|
lane="candidates"
|
||||||
count={selected.candidates.length}
|
count={selected.candidates.length}
|
||||||
@@ -515,6 +527,7 @@ export default function Inbox() {
|
|||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
onExport={() => onExport("candidates")}
|
onExport={() => onExport("candidates")}
|
||||||
/>
|
/>
|
||||||
|
</RoleGate>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="unmatched"
|
lane="unmatched"
|
||||||
count={selected.unmatched.length}
|
count={selected.unmatched.length}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { Login } from "./Login";
|
||||||
|
|
||||||
|
vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() }));
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const login = vi.fn();
|
||||||
|
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
user: null,
|
||||||
|
status: "unauthenticated",
|
||||||
|
login,
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
return { login };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Login page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits username + password", async () => {
|
||||||
|
const { login } = setup();
|
||||||
|
login.mockResolvedValue(undefined);
|
||||||
|
const { getByLabelText, getByRole } = render(
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<div>HOME</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.change(getByLabelText(/username/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(getByLabelText(/password/i), {
|
||||||
|
target: { value: "hunter2hunter2" },
|
||||||
|
});
|
||||||
|
fireEvent.click(getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(login).toHaveBeenCalledWith("alice", "hunter2hunter2")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error on failed login", async () => {
|
||||||
|
const { login } = setup();
|
||||||
|
login.mockRejectedValue(
|
||||||
|
Object.assign(new Error("401"), { code: "invalid_credentials" })
|
||||||
|
);
|
||||||
|
const { getByLabelText, getByRole, getByText } = render(
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.change(getByLabelText(/username/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(getByLabelText(/password/i), {
|
||||||
|
target: { value: "wrong" },
|
||||||
|
});
|
||||||
|
fireEvent.click(getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getByText(/username or password is incorrect/i)).toBeTruthy()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to `next` query param on success", async () => {
|
||||||
|
const { login } = setup();
|
||||||
|
login.mockResolvedValue(undefined);
|
||||||
|
const { getByLabelText, getByRole, getByText } = render(
|
||||||
|
<MemoryRouter initialEntries={["/login?next=/claims"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/claims" element={<div>CLAIMS</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.change(getByLabelText(/username/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(getByLabelText(/password/i), {
|
||||||
|
target: { value: "hunter2hunter2" },
|
||||||
|
});
|
||||||
|
fireEvent.click(getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() => expect(getByText("CLAIMS")).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in screen. Mounted by the `/login` route in `App.tsx`, which
|
||||||
|
* sits outside `<RequireAuth>` so unauthenticated operators can reach
|
||||||
|
* it. The page is also reachable via the auto-redirect on a 401 from
|
||||||
|
* `authedFetch` — that path appends `?next=<current>` so the operator
|
||||||
|
* lands back on the page they were trying to view.
|
||||||
|
*
|
||||||
|
* Error mapping is driven by the backend's `error` code on the
|
||||||
|
* `ApiError` thrown from `authApi.login`. The codes are defined in
|
||||||
|
* `backend/src/cyclone/auth/routes.py::login`.
|
||||||
|
*/
|
||||||
|
export function Login() {
|
||||||
|
const { user, login } = useAuth();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const next = params.get("next") ?? "/";
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Already authenticated — bounce to the original destination (or
|
||||||
|
// dashboard) so we don't show a sign-in form to a logged-in user.
|
||||||
|
if (user) return <Navigate to={next} replace />;
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate(next, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string })?.code ?? "error";
|
||||||
|
if (code === "invalid_credentials") {
|
||||||
|
setError("Username or password is incorrect.");
|
||||||
|
} else if (code === "account_disabled") {
|
||||||
|
setError("Account is disabled. Contact your administrator.");
|
||||||
|
} else if (code === "rate_limited") {
|
||||||
|
setError("Too many attempts. Try again shortly.");
|
||||||
|
} else {
|
||||||
|
setError("Sign in failed. Try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background text-foreground p-6">
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="w-full max-w-sm p-8 rounded-lg border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold mb-1 tracking-tight">Cyclone</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Sign in to continue.
|
||||||
|
</p>
|
||||||
|
<label className="block mb-4">
|
||||||
|
<span className="text-sm font-medium">Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded bg-background border border-border text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block mb-5">
|
||||||
|
<span className="text-sm font-medium">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="mt-1 w-full py-2 px-3 rounded bg-background border border-border text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
className="mb-4 text-sm text-red-400 bg-red-400/10 border border-red-400/30 rounded px-3 py-2"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full py-2 rounded bg-primary text-primary-foreground font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{submitting ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,25 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ReconciliationPage } from "./Reconciliation";
|
import { ReconciliationPage } from "./Reconciliation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
// "Match selected" is wrapped in RoleGate, which reads the auth
|
||||||
|
// context. Stub useAuth to return an admin user so the gate renders
|
||||||
|
// the button synchronously on first render — no AuthProvider + /me
|
||||||
|
// probe needed in the test harness.
|
||||||
|
vi.mock("@/auth/useAuth", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
status: "authenticated" as const,
|
||||||
|
user: {
|
||||||
|
id: "test-admin",
|
||||||
|
username: "test-admin",
|
||||||
|
role: "admin" as const,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the
|
// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the
|
||||||
// methods this page touches need to be stubbed; ApiError is re-exported so
|
// methods this page touches need to be stubbed; ApiError is re-exported so
|
||||||
// the page can branch on .status in its catch handler.
|
// the page can branch on .status in its catch handler.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { RemitDrawer } from "@/components/RemitDrawer";
|
|||||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RoleGate } from "@/auth/RoleGate";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-column manual reconciliation surface. The operator picks one row from
|
* Two-column manual reconciliation surface. The operator picks one row from
|
||||||
@@ -613,6 +614,13 @@ export function ReconciliationPage() {
|
|||||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Match selected is the only true write affordance on
|
||||||
|
this page — it transitions a claim from "submitted" /
|
||||||
|
"rejected" to "paid" / "partial" / "denied" /
|
||||||
|
"received" via `record_manual_match`. Viewers can
|
||||||
|
still pick rows and see the selection chrome, but
|
||||||
|
the button itself is hidden for them. */}
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleMatch}
|
onClick={handleMatch}
|
||||||
@@ -621,6 +629,7 @@ export function ReconciliationPage() {
|
|||||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Match selected
|
Match selected
|
||||||
</Button>
|
</Button>
|
||||||
|
</RoleGate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type {
|
|||||||
ServicePayment,
|
ServicePayment,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RoleGate } from "@/auth/RoleGate";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Streaming state — every claim that arrives from the backend accumulates
|
// Streaming state — every claim that arrives from the backend accumulates
|
||||||
@@ -706,6 +707,23 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Write affordances — dropzone + Parse button — are gated by
|
||||||
|
RoleGate so a viewer-role account can SEE the configuration
|
||||||
|
controls (kind, payer) above but can't actually ingest a file. */}
|
||||||
|
<RoleGate
|
||||||
|
allow={["admin", "user"]}
|
||||||
|
fallback={
|
||||||
|
<div className="relative rounded-lg border border-dashed border-border/70 bg-muted/20 px-6 py-12 min-h-[200px] flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||||
|
<UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
|
||||||
|
<div className="text-[13.5px] font-medium text-foreground">
|
||||||
|
Read-only access
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em]">
|
||||||
|
Your role (viewer) cannot ingest new files.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* Drop area — dashed border at idle, accent glow on drag */}
|
{/* Drop area — dashed border at idle, accent glow on drag */}
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -802,6 +820,7 @@ export function Upload() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RoleGate>
|
||||||
|
|
||||||
{/* Action bar — backend status on the left, controls on the right */}
|
{/* Action bar — backend status on the left, controls on the right */}
|
||||||
<div className="mt-5 pt-5 border-t border-border/40 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-5 pt-5 border-t border-border/40 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -821,6 +840,11 @@ export function Upload() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Parse button — the second half of the write affordance.
|
||||||
|
Wrapped independently so the dropzone's `fallback`
|
||||||
|
message doesn't sit next to a disabled-looking Parse
|
||||||
|
button when the role is viewer. */}
|
||||||
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{file ? (
|
{file ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -851,6 +875,7 @@ export function Upload() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</RoleGate>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -860,3 +860,18 @@ export interface InboxClaimRow {
|
|||||||
totalLines: number;
|
totalLines: number;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth (admin/user/viewer). Mirrors `users.to_public()` on the backend
|
||||||
|
// (backend/src/cyclone/auth/users.py). Role is the triple ("admin" |
|
||||||
|
// "user" | "viewer"); `disabledAt` is non-null when the account has been
|
||||||
|
// disabled by an admin. `createdAt` mirrors the original DB column.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "user" | "viewer";
|
||||||
|
createdAt: string | null;
|
||||||
|
disabledAt?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user