Compare commits
10 Commits
5bb9588f09
...
8fc3d9adda
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc3d9adda | |||
| 35e0f5a422 | |||
| a25504bd3a | |||
| 70280f70bb | |||
| a933672411 | |||
| ca40fd72c3 | |||
| 4402a993d1 | |||
| cb87456575 | |||
| 3d0c7766f0 | |||
| e76b514872 |
+17
-4
@@ -1,7 +1,20 @@
|
||||
# Cyclone — environment configuration
|
||||
# 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
|
||||
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
|
||||
# the in-memory sample data store and disable real EDI parsing.
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
# Required on first boot. Cyclone refuses to start without these unless
|
||||
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
|
||||
# Min 12 chars for password.
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
The Claims, Remittances, and Activity pages stay current without
|
||||
|
||||
+65
-64
@@ -29,12 +29,13 @@ from contextlib import asynccontextmanager
|
||||
from time import monotonic
|
||||
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.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone import __version__, db
|
||||
from cyclone.auth.deps import matrix_gate
|
||||
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
||||
from sqlalchemy import desc, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -283,9 +284,9 @@ app.add_middleware(SecurityHeadersMiddleware)
|
||||
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(acks.router)
|
||||
app.include_router(ta1_acks.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
|
||||
app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
|
||||
app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
@@ -351,7 +352,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(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -537,7 +538,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(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -688,7 +689,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
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(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -815,7 +816,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
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(
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
@@ -911,7 +912,7 @@ def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
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(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -1031,7 +1032,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(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> Any:
|
||||
@@ -1041,7 +1042,7 @@ def list_277ca_acks_endpoint(
|
||||
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:
|
||||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||||
row = store.get_277ca_ack(ack_id)
|
||||
@@ -1105,7 +1106,7 @@ 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():
|
||||
"""Return all Inbox lanes in one call."""
|
||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||||
@@ -1123,7 +1124,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):
|
||||
"""Manually link a remit to a claim."""
|
||||
claim_id = body.get("claim_id")
|
||||
@@ -1152,7 +1153,7 @@ def inbox_match_candidate(remit_id: str, body: dict):
|
||||
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):
|
||||
"""Add candidate pairs to the session-scoped dismissed set."""
|
||||
pairs = body.get("pairs") or []
|
||||
@@ -1178,7 +1179,7 @@ def inbox_dismiss_candidates(body: dict):
|
||||
# (the timestamp is not bumped). Returns the count actually transitioned
|
||||
# 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):
|
||||
"""Mark Payer-Rejected claims as acknowledged by the operator."""
|
||||
claim_ids = body.get("claim_ids") or []
|
||||
@@ -1238,7 +1239,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(
|
||||
request: Request,
|
||||
body: dict,
|
||||
@@ -1332,7 +1333,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):
|
||||
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
||||
|
||||
@@ -1541,7 +1542,7 @@ 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):
|
||||
"""Stream a CSV for a single lane."""
|
||||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||||
@@ -1594,7 +1595,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
@app.get("/api/batches")
|
||||
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
||||
def list_batches(
|
||||
request: Request,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
@@ -1628,7 +1629,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:
|
||||
rec = store.get(batch_id)
|
||||
if rec is None:
|
||||
@@ -1639,7 +1640,7 @@ def get_batch(batch_id: str) -> Any:
|
||||
return json.loads(rec.result.model_dump_json())
|
||||
|
||||
|
||||
@app.get("/api/claims")
|
||||
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
|
||||
def list_claims(
|
||||
request: Request,
|
||||
batch_id: str | None = Query(None),
|
||||
@@ -1685,7 +1686,7 @@ def list_claims(
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/claims/stream")
|
||||
@app.get("/api/claims/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def claims_stream(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
@@ -1732,7 +1733,7 @@ async def claims_stream(
|
||||
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:
|
||||
"""Return one claim with full drawer context (SP4).
|
||||
|
||||
@@ -1757,7 +1758,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
|
||||
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):
|
||||
"""Return the claim as a regenerated X12 837P file (SP8).
|
||||
|
||||
@@ -1809,7 +1810,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:
|
||||
"""Per-line reconciliation view for the ClaimDrawer tab.
|
||||
|
||||
@@ -2006,7 +2007,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:
|
||||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||||
|
||||
@@ -2023,7 +2024,7 @@ def get_reconciliation_unmatched() -> dict:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/batch-diff")
|
||||
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
|
||||
def get_batch_diff(
|
||||
a: str | None = Query(None),
|
||||
b: str | None = Query(None),
|
||||
@@ -2070,7 +2071,7 @@ def get_batch_diff(
|
||||
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:
|
||||
"""Manually pair a Claim with a Remittance (operator override).
|
||||
|
||||
@@ -2115,7 +2116,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:
|
||||
"""Remove the current match for a Claim; reset Claim to submitted.
|
||||
|
||||
@@ -2147,7 +2148,7 @@ def post_reconciliation_unmatch(body: dict) -> dict:
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/remittances")
|
||||
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
|
||||
def list_remittances(
|
||||
request: Request,
|
||||
batch_id: str | None = Query(None),
|
||||
@@ -2186,7 +2187,7 @@ def list_remittances(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/remittances/stream")
|
||||
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def remittances_stream(
|
||||
request: Request,
|
||||
payer: str | None = Query(None),
|
||||
@@ -2225,7 +2226,7 @@ async def remittances_stream(
|
||||
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:
|
||||
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
||||
|
||||
@@ -2242,7 +2243,7 @@ def get_remittance(remittance_id: str) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
@app.get("/api/providers")
|
||||
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
||||
def list_providers(
|
||||
request: Request,
|
||||
npi: str | None = Query(None),
|
||||
@@ -2272,7 +2273,7 @@ def list_providers(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/activity")
|
||||
@app.get("/api/activity", dependencies=[Depends(matrix_gate)])
|
||||
def list_activity(
|
||||
request: Request,
|
||||
kind: str | None = Query(None),
|
||||
@@ -2299,7 +2300,7 @@ def list_activity(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/activity/stream")
|
||||
@app.get("/api/activity/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def activity_stream(
|
||||
request: Request,
|
||||
kind: str | None = Query(None),
|
||||
@@ -2447,7 +2448,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
|
||||
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:
|
||||
"""Build a 270 eligibility inquiry from a small JSON body.
|
||||
|
||||
@@ -2473,7 +2474,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(
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
@@ -2533,7 +2534,7 @@ async def post_eligibility_parse_271(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/clearhouse")
|
||||
@app.get("/api/clearhouse", dependencies=[Depends(matrix_gate)])
|
||||
def get_clearhouse():
|
||||
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
|
||||
ch = store.get_clearhouse()
|
||||
@@ -2542,7 +2543,7 @@ def get_clearhouse():
|
||||
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(request: Request, body: dict):
|
||||
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
||||
|
||||
@@ -2729,7 +2730,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)):
|
||||
"""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)]
|
||||
@@ -2740,7 +2741,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(
|
||||
entity_type: str | None = Query(default=None),
|
||||
entity_id: str | None = Query(default=None),
|
||||
@@ -2782,7 +2783,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:
|
||||
"""Walk the audit-log chain and verify every row's hash.
|
||||
|
||||
@@ -2822,7 +2823,7 @@ from cyclone import secrets as _secrets
|
||||
_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:
|
||||
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
|
||||
|
||||
@@ -2982,7 +2983,7 @@ def _backup_or_503():
|
||||
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:
|
||||
"""Take an encrypted backup right now. Returns the new backup metadata."""
|
||||
from cyclone import audit_log as _audit
|
||||
@@ -3036,7 +3037,7 @@ def backup_create() -> Any:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/backup/list")
|
||||
@app.get("/api/admin/backup/list", dependencies=[Depends(matrix_gate)])
|
||||
def backup_list(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
status: str | None = Query(default=None),
|
||||
@@ -3065,7 +3066,7 @@ def backup_list(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/backup/status")
|
||||
@app.get("/api/admin/backup/status", dependencies=[Depends(matrix_gate)])
|
||||
def backup_status() -> Any:
|
||||
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
|
||||
svc = _backup_or_503()
|
||||
@@ -3079,7 +3080,7 @@ def backup_status() -> Any:
|
||||
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:
|
||||
"""Decrypt + checksum-verify a backup against its sidecar."""
|
||||
svc = _backup_or_503()
|
||||
@@ -3098,7 +3099,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:
|
||||
"""First step of the two-step restore. Returns a ``restore_token``."""
|
||||
svc = _backup_or_503()
|
||||
@@ -3126,7 +3127,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(
|
||||
backup_id: int,
|
||||
body: dict | None = None,
|
||||
@@ -3178,7 +3179,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:
|
||||
"""Apply the retention policy now. Returns the deleted paths."""
|
||||
from cyclone import audit_log as _audit
|
||||
@@ -3206,7 +3207,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:
|
||||
"""Begin the backup scheduler loop."""
|
||||
try:
|
||||
@@ -3217,7 +3218,7 @@ async def backup_scheduler_start() -> Any:
|
||||
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:
|
||||
"""Stop the backup scheduler loop."""
|
||||
try:
|
||||
@@ -3228,7 +3229,7 @@ async def backup_scheduler_stop() -> Any:
|
||||
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:
|
||||
"""Run one backup tick now (create + prune + audit)."""
|
||||
try:
|
||||
@@ -3262,7 +3263,7 @@ def _scheduler_or_503():
|
||||
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:
|
||||
"""Begin polling the MFT inbound path every poll_interval_seconds."""
|
||||
sched = _scheduler_or_503()
|
||||
@@ -3270,7 +3271,7 @@ async def scheduler_start() -> Any:
|
||||
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:
|
||||
"""Stop polling. Waits up to 30s for the current tick to finish."""
|
||||
sched = _scheduler_or_503()
|
||||
@@ -3278,7 +3279,7 @@ async def scheduler_stop() -> Any:
|
||||
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:
|
||||
"""Run a single poll cycle synchronously and return the result.
|
||||
|
||||
@@ -3291,14 +3292,14 @@ async def scheduler_tick() -> Any:
|
||||
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:
|
||||
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
|
||||
sched = _scheduler_or_503()
|
||||
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(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
status: str | None = Query(default=None),
|
||||
@@ -3346,7 +3347,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):
|
||||
p = store.get_provider(npi)
|
||||
if p is None:
|
||||
@@ -3420,12 +3421,12 @@ def get_configured_provider(npi: str):
|
||||
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)):
|
||||
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):
|
||||
"""List all (transaction_type, config_json) blocks for a payer."""
|
||||
from cyclone import payers as payer_loader
|
||||
@@ -3476,7 +3477,7 @@ def _clear_summary_cache() -> None:
|
||||
# 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):
|
||||
"""Payer-level rollup for the drill-down panel.
|
||||
|
||||
@@ -3562,7 +3563,7 @@ def get_payer_summary(payer_id: str):
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/admin/reload-config")
|
||||
@app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
|
||||
def reload_config():
|
||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||
from cyclone import payers as payer_loader
|
||||
@@ -3584,7 +3585,7 @@ 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)
|
||||
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
|
||||
|
||||
|
||||
__all__ = ["app"]
|
||||
|
||||
@@ -102,3 +102,39 @@ def require_role(*allowed: Role):
|
||||
)
|
||||
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
|
||||
|
||||
@@ -24,17 +24,28 @@ def _auto_init_db(tmp_path, monkeypatch):
|
||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||
does not invoke the FastAPI lifespan handler unless used as a context
|
||||
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")
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.pubsub import EventBus
|
||||
from cyclone.auth import deps
|
||||
|
||||
db._reset_for_tests()
|
||||
db.init_db()
|
||||
app.state.event_bus = EventBus()
|
||||
deps.AUTH_DISABLED = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
deps.AUTH_DISABLED = False
|
||||
app.state.event_bus = None
|
||||
db._reset_for_tests()
|
||||
@@ -13,7 +13,11 @@ from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
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))
|
||||
|
||||
@@ -13,7 +13,12 @@ from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
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))
|
||||
|
||||
@@ -13,7 +13,11 @@ from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
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))
|
||||
|
||||
@@ -13,7 +13,11 @@ from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
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))
|
||||
|
||||
@@ -11,7 +11,11 @@ from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_users():
|
||||
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()
|
||||
|
||||
@@ -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,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
|
||||
+10
-1
@@ -13,6 +13,8 @@ import { Acks } from "@/pages/Acks";
|
||||
import { Batches } from "@/pages/Batches";
|
||||
import { BatchDiff } from "@/pages/BatchDiff";
|
||||
import Inbox from "@/pages/Inbox";
|
||||
import { Login } from "@/pages/Login";
|
||||
import { RequireAuth } from "@/auth/RequireAuth";
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
@@ -27,7 +29,14 @@ export default function App() {
|
||||
return (
|
||||
<DrillStackProvider>
|
||||
<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 path="claims" element={<Claims />} />
|
||||
<Route path="remittances" element={<Remittances />} />
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ describe("auth/api fetch wrapper", () => {
|
||||
statusText: "Unauthorized",
|
||||
headers: new Headers(),
|
||||
json: async () => ({ error: "session_expired" }),
|
||||
} as Response);
|
||||
} 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).
|
||||
@@ -54,7 +54,7 @@ describe("auth/api fetch wrapper", () => {
|
||||
statusText: "Forbidden",
|
||||
headers: new Headers(),
|
||||
json: async () => ({ error: "forbidden" }),
|
||||
} as Response);
|
||||
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
@@ -75,7 +75,7 @@ describe("auth/api fetch wrapper", () => {
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ hello: "world" }),
|
||||
} as Response);
|
||||
} 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" });
|
||||
|
||||
@@ -88,6 +88,54 @@ export async function authedFetch<T = unknown>(
|
||||
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
|
||||
|
||||
+60
-13
@@ -7,6 +7,7 @@ import {
|
||||
Inbox as InboxIcon,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Receipt,
|
||||
Stethoscope,
|
||||
Upload as UploadIcon,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useReconciliation } from "@/hooks/useReconciliation";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
@@ -188,25 +190,70 @@ export function Sidebar() {
|
||||
</nav>
|
||||
|
||||
{/* 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="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
|
||||
<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>
|
||||
<CurrentUserCard />
|
||||
</div>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
||||
|
||||
+97
-157
@@ -43,6 +43,10 @@ import type {
|
||||
UnmatchedResponse,
|
||||
BatchSummary as ParserBatchSummary,
|
||||
} from "@/types";
|
||||
import {
|
||||
authedFetch,
|
||||
authedFetchResponse,
|
||||
} from "@/auth/api";
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||
|
||||
@@ -167,10 +171,6 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(path: string): string {
|
||||
return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
async function readErrorBody(res: Response): Promise<string> {
|
||||
try {
|
||||
const t = await res.text();
|
||||
@@ -296,11 +296,18 @@ async function parse837(
|
||||
form.append("file", file, file.name);
|
||||
|
||||
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",
|
||||
body: form,
|
||||
headers: { Accept: accept },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
@@ -332,11 +339,17 @@ async function parse835(
|
||||
form.append("file", file, file.name);
|
||||
|
||||
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",
|
||||
body: form,
|
||||
headers: { Accept: accept },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
@@ -362,16 +375,7 @@ async function parse999(
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const res = await fetch(joinUrl("/api/parse-999"), {
|
||||
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 {
|
||||
const body = await authedFetch<{
|
||||
ack: {
|
||||
id: number;
|
||||
accepted_count: number;
|
||||
@@ -382,7 +386,14 @@ async function parse999(
|
||||
raw_999_text: string;
|
||||
};
|
||||
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 {
|
||||
ack: {
|
||||
id: body.ack.id,
|
||||
@@ -403,14 +414,14 @@ async function parse999(
|
||||
|
||||
async function health(): Promise<HealthResponse | null> {
|
||||
if (!isConfigured) return null;
|
||||
const res = await fetch(joinUrl("/api/health"));
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new Error(
|
||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
||||
);
|
||||
// health() is best-effort — used by the side-scan ping. Don't let a
|
||||
// 401 from authedFetch redirect the operator; just return null when
|
||||
// anything goes wrong.
|
||||
try {
|
||||
return await authedFetch<HealthResponse>("/api/health");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as HealthResponse;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -423,48 +434,26 @@ async function listBatches(limit?: number): Promise<BatchSummary[]> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const params: Record<string, unknown> = {};
|
||||
if (limit !== undefined) params.limit = limit;
|
||||
const res = await fetch(joinUrl(`/api/batches${qs(params)}`), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new Error(
|
||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
||||
const body = await authedFetch<{ items: BatchSummary[] }>(
|
||||
`/api/batches${qs(params)}`
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as { items: BatchSummary[] };
|
||||
return body.items;
|
||||
}
|
||||
|
||||
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new Error(
|
||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
||||
return authedFetch<ParseResult837 | ParseResult835>(
|
||||
`/api/batches/${encodeURIComponent(id)}`
|
||||
);
|
||||
}
|
||||
return (await res.json()) as ParseResult837 | ParseResult835;
|
||||
}
|
||||
|
||||
async function listClaims<T = unknown>(
|
||||
params: ListClaimsParams
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`),
|
||||
{ headers: { Accept: "application/json" } }
|
||||
return authedFetch<PaginatedResponse<T>>(
|
||||
`/api/claims${qs(params as Record<string, unknown>)}`
|
||||
);
|
||||
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 +469,31 @@ async function listClaims<T = unknown>(
|
||||
*/
|
||||
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new ApiError(res.status, detail || res.statusText);
|
||||
try {
|
||||
return await authedFetch<ClaimDetail>(
|
||||
`/api/claims/${encodeURIComponent(id)}`
|
||||
);
|
||||
} catch (err) {
|
||||
// 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 +515,14 @@ async function serializeClaim837(
|
||||
id: string,
|
||||
): Promise<{ text: string; filename: string }> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
|
||||
// authedFetchText gives us the body as a string with the same
|
||||
// 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) {
|
||||
const detail = await readErrorBody(res);
|
||||
@@ -530,17 +542,9 @@ async function listRemittances<T = unknown>(
|
||||
params: ListRemittancesParams
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`),
|
||||
{ headers: { Accept: "application/json" } }
|
||||
return authedFetch<PaginatedResponse<T>>(
|
||||
`/api/remittances${qs(params as Record<string, unknown>)}`
|
||||
);
|
||||
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 +553,7 @@ async function listRemittances<T = unknown>(
|
||||
*/
|
||||
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/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;
|
||||
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -580,34 +577,16 @@ async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
||||
if (typeof b !== "string" || b.length === 0) {
|
||||
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
|
||||
}
|
||||
const res = await fetch(
|
||||
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;
|
||||
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
|
||||
}
|
||||
|
||||
async function listProviders<T = unknown>(
|
||||
params: ListProvidersParams = {}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`),
|
||||
{ headers: { Accept: "application/json" } }
|
||||
return authedFetch<PaginatedResponse<T>>(
|
||||
`/api/providers${qs(params as Record<string, unknown>)}`
|
||||
);
|
||||
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 +653,9 @@ async function listActivity<T = unknown>(
|
||||
params: ListActivityParams = {}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`),
|
||||
{ headers: { Accept: "application/json" } }
|
||||
return authedFetch<PaginatedResponse<T>>(
|
||||
`/api/activity${qs(params as Record<string, unknown>)}`
|
||||
);
|
||||
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 +666,7 @@ async function listActivity<T = unknown>(
|
||||
|
||||
async function listUnmatched(): Promise<UnmatchedResponse> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/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;
|
||||
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
|
||||
}
|
||||
|
||||
async function matchRemit(
|
||||
@@ -710,36 +674,20 @@ async function matchRemit(
|
||||
remitId: string
|
||||
): Promise<MatchResponse> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/api/reconciliation/match`), {
|
||||
return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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 }> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/api/reconciliation/unmatch`), {
|
||||
return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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 +726,12 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const query: Record<string, unknown> = {};
|
||||
if (params.limit !== undefined) query.limit = params.limit;
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/acks${qs(query)}`),
|
||||
{ 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 { items: RawAckRow[]; total: number; returned: number; has_more: boolean };
|
||||
const body = await authedFetch<{
|
||||
items: RawAckRow[];
|
||||
total: number;
|
||||
returned: number;
|
||||
has_more: boolean;
|
||||
}>(`/api/acks${qs(query)}`);
|
||||
return {
|
||||
items: body.items.map(mapAck),
|
||||
total: body.total,
|
||||
@@ -797,14 +742,9 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
|
||||
|
||||
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/api/acks/${encodeURIComponent(String(id))}`), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
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 };
|
||||
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
|
||||
`/api/acks/${encodeURIComponent(String(id))}`
|
||||
);
|
||||
return { ...mapAck(row), rawJson: row.raw_json };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AuthProvider } from "@/auth/AuthProvider";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
@@ -18,7 +19,13 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<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 />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -8,6 +8,27 @@ import Inbox from "./Inbox";
|
||||
import * as inboxApi from "@/lib/inbox-api";
|
||||
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
|
||||
// claim drilldown to /claims?claim=ID), so the render harness needs
|
||||
// a Router context. We use a fresh QueryClient per test so the
|
||||
|
||||
+14
-1
@@ -29,6 +29,7 @@ import {
|
||||
acknowledgePayerRejected,
|
||||
} from "@/lib/inbox-api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
import { RoleGate } from "@/auth/RoleGate";
|
||||
|
||||
type LaneKey =
|
||||
| "rejected"
|
||||
@@ -490,7 +491,14 @@ export default function Inbox() {
|
||||
</div>
|
||||
</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
|
||||
lane="rejected"
|
||||
count={selected.rejected.length}
|
||||
@@ -499,6 +507,8 @@ export default function Inbox() {
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("rejected")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||
<BulkBar
|
||||
lane="payer_rejected"
|
||||
count={selected.payer_rejected.length}
|
||||
@@ -507,6 +517,8 @@ export default function Inbox() {
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("payer_rejected")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||
<BulkBar
|
||||
lane="candidates"
|
||||
count={selected.candidates.length}
|
||||
@@ -515,6 +527,7 @@ export default function Inbox() {
|
||||
onDismiss={onDismiss}
|
||||
onExport={() => onExport("candidates")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<BulkBar
|
||||
lane="unmatched"
|
||||
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 { 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
|
||||
// methods this page touches need to be stubbed; ApiError is re-exported so
|
||||
// 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 { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RoleGate } from "@/auth/RoleGate";
|
||||
|
||||
/**
|
||||
* 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" />
|
||||
Clear
|
||||
</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
|
||||
size="sm"
|
||||
onClick={handleMatch}
|
||||
@@ -621,6 +629,7 @@ export function ReconciliationPage() {
|
||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||
Match selected
|
||||
</Button>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
ServicePayment,
|
||||
} from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RoleGate } from "@/auth/RoleGate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming state — every claim that arrives from the backend accumulates
|
||||
@@ -706,6 +707,23 @@ export function Upload() {
|
||||
</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 */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
@@ -802,6 +820,7 @@ export function Upload() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RoleGate>
|
||||
|
||||
{/* 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">
|
||||
@@ -821,6 +840,11 @@ export function Upload() {
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
{file ? (
|
||||
<Button
|
||||
@@ -851,6 +875,7 @@ export function Upload() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user