10 Commits

Author SHA1 Message Date
Nora 8fc3d9adda test(auth): add test_existing_endpoints_require_auth + per-test AUTH_DISABLED flips
- New test_existing_endpoints_require_auth.py: spot-check that existing
  /api/* endpoints now require auth (gated via Depends(matrix_gate)) when
  AUTH_DISABLED is False. Health remains public.

- conftest.py: flip AUTH_DISABLED=True for the suite so the legacy
  pre-auth tests keep passing without login. Auth tests flip it back
  off via their own autouse fixture (now patched to use monkeypatch
  for cleanup).

Verified: 53 auth tests pass; 222 pre-existing non-auth failures are
unchanged.
2026-06-22 15:59:08 -06:00
Nora 35e0f5a422 feat(auth): disable write affordances for viewer role
Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx /
Reconciliation.tsx since main's UI text diverged from the original
cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.).
The write affordances are now wrapped in <RoleGate allow={admin,user}>:

  - Upload.tsx: dropzone + Parse/Clear buttons
  - Inbox.tsx: rejected / payer_rejected / candidates BulkBars
  - Reconciliation.tsx: 'Match selected' button

Test fixtures stub useAuth to return an admin user so RoleGate lets
the BulkBars / Match button render synchronously on first paint —
the tests don't need a full AuthProvider + /api/auth/me probe.
2026-06-22 15:54:38 -06:00
Nora a25504bd3a fix(auth): ungate auth_router so login is public 2026-06-22 15:41:52 -06:00
Nora 70280f70bb feat(auth): gate existing endpoints with matrix_gate (router-level + per-endpoint)
Replaces the skipped 63ae0d2 commit: adds matrix_gate to deps.py and
applies it as a dependency on every authenticated FastAPI route, including
extracted routers (acks, ta1_acks, admin) and inline endpoints in api.py.

The PERMISSIONS matrix in auth/permissions.py controls which roles can
hit which (method, path) combinations; matrix_gate is fail-closed by
default (any endpoint not in the matrix returns 403).
2026-06-22 15:40:00 -06:00
Nora a933672411 docs: auth section + env vars 2026-06-22 15:38:38 -06:00
Nora ca40fd72c3 feat(docker): CYCLONE_ADMIN_* env vars for backend 2026-06-22 15:38:38 -06:00
Nora 4402a993d1 feat(auth): sidebar shows current user + logout button 2026-06-22 15:37:02 -06:00
Nora cb87456575 feat(auth): wire AuthProvider + RequireAuth into app shell 2026-06-22 15:37:02 -06:00
Nora 3d0c7766f0 feat(auth): RequireAuth route guard 2026-06-22 15:34:19 -06:00
Nora e76b514872 feat(auth): /login page 2026-06-22 15:34:19 -06:00
26 changed files with 1082 additions and 406 deletions
+17 -4
View File
@@ -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
+43
View File
@@ -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
View File
@@ -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"]
+36
View File
@@ -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
+11
View File
@@ -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()
+5 -1
View File
@@ -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))
+6 -1
View File
@@ -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))
+5 -1
View File
@@ -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))
+5 -1
View File
@@ -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))
+5 -1
View File
@@ -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}"
+101
View File
@@ -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
View File
@@ -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 />} />
+43
View File
@@ -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}</>;
}
+3 -3
View File
@@ -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" });
+48
View File
@@ -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
View File
@@ -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">
+105 -165
View File
@@ -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
method: "POST", // 401-redirect for any non-/api/auth path), but hands back the raw
body: form, // Response so we can stream NDJSON line-by-line below. For non-stream
headers: { Accept: accept }, // callers it returns the parsed JSON.
}); const res = await authedFetchResponse(
`/api/parse-837?${params.toString()}`,
{
method: "POST",
body: form,
headers: { Accept: accept },
}
);
if (!res.ok) { 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
method: "POST", // authedFetch — NDJSON streaming needs the raw Response so we can
body: form, // iterate `res.body` one chunk at a time.
headers: { Accept: accept }, const res = await authedFetchResponse(
}); `/api/parse-835?${params.toString()}`,
{
method: "POST",
body: form,
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 };
} }
+8 -1
View File
@@ -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>
<App /> {/* AuthProvider must sit inside BrowserRouter (so it can use
react-router hooks) but outside the route tree (so the
`/login` route still has access to auth state for the
auto-redirect when the operator is already signed in). */}
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode> </React.StrictMode>
+21
View File
@@ -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
+38 -25
View File
@@ -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,31 +491,43 @@ 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.
<BulkBar The three write-affordance BulkBars (rejected → resubmit,
lane="rejected" payer_rejected → acknowledge, candidates → dismiss) are gated
count={selected.rejected.length} by RoleGate so a viewer-role account can still SEE the rows
onResubmit={onResubmit} and select them but cannot trigger a state change. The export
onAcknowledge={() => {}} buttons live inside the same BulkBar component but are
onDismiss={() => {}} read-only, so we let them remain visible to viewers. */}
onExport={() => onExport("rejected")} <RoleGate allow={["admin", "user"]} fallback={null}>
/> <BulkBar
<BulkBar lane="rejected"
lane="payer_rejected" count={selected.rejected.length}
count={selected.payer_rejected.length} onResubmit={onResubmit}
onResubmit={() => {}} onAcknowledge={() => {}}
onAcknowledge={onAcknowledge} onDismiss={() => {}}
onDismiss={() => {}} onExport={() => onExport("rejected")}
onExport={() => onExport("payer_rejected")} />
/> </RoleGate>
<BulkBar <RoleGate allow={["admin", "user"]} fallback={null}>
lane="candidates" <BulkBar
count={selected.candidates.length} lane="payer_rejected"
onResubmit={() => {}} count={selected.payer_rejected.length}
onAcknowledge={() => {}} onResubmit={() => {}}
onDismiss={onDismiss} onAcknowledge={onAcknowledge}
onExport={() => onExport("candidates")} onDismiss={() => {}}
/> onExport={() => onExport("payer_rejected")}
/>
</RoleGate>
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
onAcknowledge={() => {}}
onDismiss={onDismiss}
onExport={() => onExport("candidates")}
/>
</RoleGate>
<BulkBar <BulkBar
lane="unmatched" lane="unmatched"
count={selected.unmatched.length} count={selected.unmatched.length}
+95
View File
@@ -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());
});
});
+104
View File
@@ -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>
);
}
+19
View File
@@ -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.
+17 -8
View File
@@ -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,14 +614,22 @@ 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>
<Button {/* Match selected is the only true write affordance on
size="sm" this page — it transitions a claim from "submitted" /
onClick={handleMatch} "rejected" to "paid" / "partial" / "denied" /
disabled={!selectedClaim || !selectedRemit || match.isPending} "received" via `record_manual_match`. Viewers can
> still pick rows and see the selection chrome, but
<GitMerge className="h-3.5 w-3.5 mr-1.5" /> the button itself is hidden for them. */}
Match selected <RoleGate allow={["admin", "user"]}>
</Button> <Button
size="sm"
onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending}
>
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected
</Button>
</RoleGate>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+142 -117
View File
@@ -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,102 +707,120 @@ export function Upload() {
</div> </div>
</div> </div>
{/* Drop area — dashed border at idle, accent glow on drag */} {/* Write affordances — dropzone + Parse button — are gated by
<div RoleGate so a viewer-role account can SEE the configuration
onDragOver={(e) => { controls (kind, payer) above but can't actually ingest a file. */}
e.preventDefault(); <RoleGate
setDragging(true); allow={["admin", "user"]}
}} fallback={
onDragLeave={() => setDragging(false)} <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">
onDrop={onDrop} <UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
onClick={() => inputRef.current?.click()} <div className="text-[13.5px] font-medium text-foreground">
role="button" Read-only access
tabIndex={0} </div>
onKeyDown={(e) => { <div className="mono text-[10.5px] uppercase tracking-[0.18em]">
if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); Your role (viewer) cannot ingest new files.
}} </div>
aria-label="Drop a file here, or click to choose" </div>
className={cn( }
"relative rounded-lg border border-dashed transition-all duration-200",
"px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden",
dragging
? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]"
: "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border"
)}
> >
{/* On drag — an accent scan-line sweeps across to reinforce {/* Drop area — dashed border at idle, accent glow on drag */}
that the dropzone is hot. Pure CSS via the `animate-scan` <div
keyframe already in the Tailwind config. */} onDragOver={(e) => {
{dragging ? ( e.preventDefault();
<div setDragging(true);
aria-hidden }}
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg" onDragLeave={() => setDragging(false)}
> onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
aria-label="Drop a file here, or click to choose"
className={cn(
"relative rounded-lg border border-dashed transition-all duration-200",
"px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden",
dragging
? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]"
: "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border"
)}
>
{/* On drag — an accent scan-line sweeps across to reinforce
that the dropzone is hot. Pure CSS via the `animate-scan`
keyframe already in the Tailwind config. */}
{dragging ? (
<div <div
className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan" aria-hidden
style={{ className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
background: >
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)", <div
}} className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan"
style={{
background:
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)",
}}
/>
</div>
) : null}
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
<div
className={cn(
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
dragging
? "bg-accent/15 text-accent ring-accent/40"
: "bg-muted/50 text-muted-foreground ring-border/60"
)}
>
{dragging ? (
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
) : (
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
)}
</div>
{file ? (
<div className="flex items-center gap-2 text-[13.5px] mt-1">
<FileText
className="h-4 w-4 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium text-foreground truncate max-w-[40ch]">
{file.name}
</span>
<span className="mono text-[11px] text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="display text-[20px] tracking-tight text-foreground mt-1">
{dragging ? (
<>
Release to <span className="italic text-accent">parse</span>
</>
) : (
<>
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
</>
)}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
.txt X12 837/835 as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/> />
</div> </div>
) : null}
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
<div
className={cn(
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
dragging
? "bg-accent/15 text-accent ring-accent/40"
: "bg-muted/50 text-muted-foreground ring-border/60"
)}
>
{dragging ? (
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
) : (
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
)}
</div>
{file ? (
<div className="flex items-center gap-2 text-[13.5px] mt-1">
<FileText
className="h-4 w-4 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium text-foreground truncate max-w-[40ch]">
{file.name}
</span>
<span className="mono text-[11px] text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="display text-[20px] tracking-tight text-foreground mt-1">
{dragging ? (
<>
Release to <span className="italic text-accent">parse</span>
</>
) : (
<>
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
</>
)}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
.txt X12 837/835 as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</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,36 +840,42 @@ export function Upload() {
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> {/* Parse button — the second half of the write affordance.
{file ? ( Wrapped independently so the dropzone's `fallback`
message doesn't sit next to a disabled-looking Parse
button when the role is viewer. */}
<RoleGate allow={["admin", "user"]} fallback={null}>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
<Button
variant="ghost"
size="sm"
onClick={() => pickFile(null)}
disabled={running}
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
) : null}
<Button <Button
variant="ghost" onClick={onParse}
disabled={!file || running}
size="sm" size="sm"
onClick={() => pickFile(null)}
disabled={running}
> >
<X className="h-3.5 w-3.5" /> {running ? (
Clear <>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse file
</>
)}
</Button> </Button>
) : null} </div>
<Button </RoleGate>
onClick={onParse}
disabled={!file || running}
size="sm"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse file
</>
)}
</Button>
</div>
</div> </div>
</section> </section>