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
|
# 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
|
||||||
|
|||||||
+65
-64
@@ -29,12 +29,13 @@ 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
|
||||||
@@ -283,9 +284,9 @@ 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)
|
@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(
|
async def parse_837(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
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(
|
async def parse_835_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
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'}"
|
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(...),
|
||||||
@@ -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'}"
|
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:
|
||||||
@@ -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'}"
|
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(...),
|
||||||
@@ -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(
|
def list_277ca_acks_endpoint(
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -1041,7 +1042,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)
|
||||||
@@ -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():
|
def inbox_lanes():
|
||||||
"""Return all Inbox lanes in one call."""
|
"""Return all Inbox lanes in one call."""
|
||||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
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):
|
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")
|
||||||
@@ -1152,7 +1153,7 @@ 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):
|
||||||
"""Add candidate pairs to the session-scoped dismissed set."""
|
"""Add candidate pairs to the session-scoped dismissed set."""
|
||||||
pairs = body.get("pairs") or []
|
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
|
# (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 []
|
||||||
@@ -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(
|
def inbox_resubmit_rejected(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: dict,
|
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):
|
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.
|
||||||
|
|
||||||
@@ -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):
|
def inbox_export_csv(lane: str):
|
||||||
"""Stream a CSV for a single lane."""
|
"""Stream a CSV for a single lane."""
|
||||||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||||||
@@ -1594,7 +1595,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),
|
||||||
@@ -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:
|
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:
|
||||||
@@ -1639,7 +1640,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),
|
||||||
@@ -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(
|
async def claims_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
@@ -1732,7 +1733,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).
|
||||||
|
|
||||||
@@ -1757,7 +1758,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).
|
||||||
|
|
||||||
@@ -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:
|
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.
|
||||||
|
|
||||||
@@ -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:
|
def get_reconciliation_unmatched() -> dict:
|
||||||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
"""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(
|
def get_batch_diff(
|
||||||
a: str | None = Query(None),
|
a: str | None = Query(None),
|
||||||
b: 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)
|
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).
|
||||||
|
|
||||||
@@ -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:
|
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.
|
||||||
|
|
||||||
@@ -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(
|
def list_remittances(
|
||||||
request: Request,
|
request: Request,
|
||||||
batch_id: str | None = Query(None),
|
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(
|
async def remittances_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
payer: str | None = Query(None),
|
payer: str | None = Query(None),
|
||||||
@@ -2225,7 +2226,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.
|
||||||
|
|
||||||
@@ -2242,7 +2243,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),
|
||||||
@@ -2272,7 +2273,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),
|
||||||
@@ -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(
|
async def activity_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
kind: str | None = Query(None),
|
kind: str | None = Query(None),
|
||||||
@@ -2447,7 +2448,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.
|
||||||
|
|
||||||
@@ -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(
|
async def post_eligibility_parse_271(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> Any:
|
) -> 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():
|
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()
|
||||||
@@ -2542,7 +2543,7 @@ 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(request: Request, 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.
|
||||||
|
|
||||||
@@ -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)):
|
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)]
|
||||||
@@ -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(
|
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),
|
||||||
@@ -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:
|
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.
|
||||||
|
|
||||||
@@ -2822,7 +2823,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.
|
||||||
|
|
||||||
@@ -2982,7 +2983,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
|
||||||
@@ -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(
|
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),
|
||||||
@@ -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:
|
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()
|
||||||
@@ -3079,7 +3080,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()
|
||||||
@@ -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:
|
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()
|
||||||
@@ -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(
|
def backup_restore_confirm(
|
||||||
backup_id: int,
|
backup_id: int,
|
||||||
body: dict | None = None,
|
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:
|
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
|
||||||
@@ -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:
|
async def backup_scheduler_start() -> Any:
|
||||||
"""Begin the backup scheduler loop."""
|
"""Begin the backup scheduler loop."""
|
||||||
try:
|
try:
|
||||||
@@ -3217,7 +3218,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:
|
||||||
@@ -3228,7 +3229,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:
|
||||||
@@ -3262,7 +3263,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()
|
||||||
@@ -3270,7 +3271,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()
|
||||||
@@ -3278,7 +3279,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.
|
||||||
|
|
||||||
@@ -3291,14 +3292,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),
|
||||||
@@ -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):
|
def get_configured_provider(npi: str):
|
||||||
p = store.get_provider(npi)
|
p = store.get_provider(npi)
|
||||||
if p is None:
|
if p is None:
|
||||||
@@ -3420,12 +3421,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
|
||||||
@@ -3476,7 +3477,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.
|
||||||
|
|
||||||
@@ -3562,7 +3563,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
|
||||||
@@ -3584,7 +3585,7 @@ from cyclone.auth.routes import router as auth_router
|
|||||||
from cyclone.auth.admin import router as admin_users_router
|
from cyclone.auth.admin import router as admin_users_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_users_router)
|
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["app"]
|
__all__ = ["app"]
|
||||||
|
|||||||
@@ -102,3 +102,39 @@ def require_role(*allowed: Role):
|
|||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
return _dep
|
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``
|
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.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()
|
app.state.event_bus = EventBus()
|
||||||
|
deps.AUTH_DISABLED = True
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
deps.AUTH_DISABLED = False
|
||||||
app.state.event_bus = None
|
app.state.event_bus = None
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
@@ -13,7 +13,11 @@ from cyclone.db import SessionLocal, User
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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:
|
with SessionLocal()() as db:
|
||||||
db.execute(delete(DbSession))
|
db.execute(delete(DbSession))
|
||||||
db.execute(delete(User))
|
db.execute(delete(User))
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from cyclone.db import SessionLocal, User
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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:
|
with SessionLocal()() as db:
|
||||||
db.execute(delete(DbSession))
|
db.execute(delete(DbSession))
|
||||||
db.execute(delete(User))
|
db.execute(delete(User))
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ from cyclone.db import SessionLocal, User
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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:
|
with SessionLocal()() as db:
|
||||||
db.execute(delete(DbSession))
|
db.execute(delete(DbSession))
|
||||||
db.execute(delete(User))
|
db.execute(delete(User))
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ from cyclone.db import SessionLocal, User
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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:
|
with SessionLocal()() as db:
|
||||||
db.execute(delete(DbSession))
|
db.execute(delete(DbSession))
|
||||||
db.execute(delete(User))
|
db.execute(delete(User))
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from cyclone.db import SessionLocal, User
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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:
|
with SessionLocal()() as db:
|
||||||
db.execute(delete(User))
|
db.execute(delete(User))
|
||||||
db.commit()
|
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 { 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,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",
|
statusText: "Unauthorized",
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
json: async () => ({ error: "session_expired" }),
|
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
|
// Stub window.location.href setter to capture navigation without
|
||||||
// actually navigating (jsdom does not implement location.href assignment).
|
// actually navigating (jsdom does not implement location.href assignment).
|
||||||
@@ -54,7 +54,7 @@ describe("auth/api fetch wrapper", () => {
|
|||||||
statusText: "Forbidden",
|
statusText: "Forbidden",
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
json: async () => ({ error: "forbidden" }),
|
json: async () => ({ error: "forbidden" }),
|
||||||
} as Response);
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
Object.defineProperty(window, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get: () => ({
|
get: () => ({
|
||||||
@@ -75,7 +75,7 @@ describe("auth/api fetch wrapper", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
json: async () => ({ hello: "world" }),
|
json: async () => ({ hello: "world" }),
|
||||||
} as Response);
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
const { authedFetch } = await import("./api");
|
const { authedFetch } = await import("./api");
|
||||||
const data = await authedFetch("/api/anything");
|
const data = await authedFetch("/api/anything");
|
||||||
expect(data).toEqual({ hello: "world" });
|
expect(data).toEqual({ hello: "world" });
|
||||||
|
|||||||
@@ -88,6 +88,54 @@ export async function authedFetch<T = unknown>(
|
|||||||
return (await res.json()) 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
|
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
|
||||||
// they bypass the 401-redirect behavior on purpose — a 401 from
|
// they bypass the 401-redirect behavior on purpose — a 401 from
|
||||||
|
|||||||
+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">
|
||||||
|
|||||||
+97
-157
@@ -43,6 +43,10 @@ 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) ?? "";
|
||||||
|
|
||||||
@@ -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> {
|
async function readErrorBody(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
@@ -296,11 +296,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 +339,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 +375,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 +386,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 +414,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 +434,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 +469,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 +515,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 +542,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 +553,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 +577,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 +653,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 +666,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 +674,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 +726,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 +742,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user