diff --git a/src/App.tsx b/src/App.tsx index 45c6fd7..8f84655 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ import { Acks } from "@/pages/Acks"; import { Batches } from "@/pages/Batches"; import { BatchDiff } from "@/pages/BatchDiff"; import Inbox from "@/pages/Inbox"; +import { Login } from "@/pages/Login"; +import { RequireAuth } from "@/auth/RequireAuth"; function NotFound() { return ( @@ -27,7 +29,14 @@ export default function App() { return ( - }> + {/* /login sits OUTSIDE the auth-gated Layout so an + unauthenticated operator can reach the sign-in screen. */} + } /> + {/* 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=`. */} + }> } /> } /> } /> diff --git a/src/auth/api.test.ts b/src/auth/api.test.ts index 710439b..89e8321 100644 --- a/src/auth/api.test.ts +++ b/src/auth/api.test.ts @@ -23,7 +23,7 @@ describe("auth/api fetch wrapper", () => { statusText: "Unauthorized", headers: new Headers(), json: async () => ({ error: "session_expired" }), - } as Response); + } as unknown as Response) as unknown as typeof globalThis.fetch; // Stub window.location.href setter to capture navigation without // actually navigating (jsdom does not implement location.href assignment). @@ -54,7 +54,7 @@ describe("auth/api fetch wrapper", () => { statusText: "Forbidden", headers: new Headers(), json: async () => ({ error: "forbidden" }), - } as Response); + } as unknown as Response) as unknown as typeof globalThis.fetch; Object.defineProperty(window, "location", { configurable: true, get: () => ({ @@ -75,7 +75,7 @@ describe("auth/api fetch wrapper", () => { status: 200, headers: new Headers(), json: async () => ({ hello: "world" }), - } as Response); + } as unknown as Response) as unknown as typeof globalThis.fetch; const { authedFetch } = await import("./api"); const data = await authedFetch("/api/anything"); expect(data).toEqual({ hello: "world" }); diff --git a/src/auth/api.ts b/src/auth/api.ts index 54dd7a9..fa81e90 100644 --- a/src/auth/api.ts +++ b/src/auth/api.ts @@ -88,6 +88,54 @@ export async function authedFetch( 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 { + 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 { + const res = await fetch(joinUrl(path), { + credentials: "include", + ...init, + }); + if (res.status === 401 && !path.startsWith("/api/auth/")) { + redirectToLogin(); + throw new ApiError(401, "session_expired"); + } + return res; +} + // --------------------------------------------------------------------------- // Auth-specific endpoints. `login` and `logout` use raw `fetch` because // they bypass the 401-redirect behavior on purpose — a 401 from diff --git a/src/lib/api.ts b/src/lib/api.ts index 1715543..3fc90ca 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -43,6 +43,10 @@ import type { UnmatchedResponse, BatchSummary as ParserBatchSummary, } from "@/types"; +import { + authedFetch, + authedFetchResponse, +} from "@/auth/api"; const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; @@ -167,10 +171,6 @@ export class ApiError extends Error { } } -function joinUrl(path: string): string { - return `${BASE_URL.replace(/\/$/, "")}${path}`; -} - async function readErrorBody(res: Response): Promise { try { const t = await res.text(); @@ -296,11 +296,18 @@ async function parse837( form.append("file", file, file.name); const accept = onProgress ? "application/x-ndjson" : "application/json"; - const res = await fetch(joinUrl(`/api/parse-837?${params.toString()}`), { - method: "POST", - body: form, - headers: { Accept: accept }, - }); + // authedFetchResponse still hits fetch() (and still does the + // 401-redirect for any non-/api/auth path), but hands back the raw + // Response so we can stream NDJSON line-by-line below. For non-stream + // callers it returns the parsed JSON. + const res = await authedFetchResponse( + `/api/parse-837?${params.toString()}`, + { + method: "POST", + body: form, + headers: { Accept: accept }, + } + ); if (!res.ok) { const detail = await readErrorBody(res); @@ -332,11 +339,17 @@ async function parse835( form.append("file", file, file.name); const accept = onProgress ? "application/x-ndjson" : "application/json"; - const res = await fetch(joinUrl(`/api/parse-835?${params.toString()}`), { - method: "POST", - body: form, - headers: { Accept: accept }, - }); + // See parse837 for why this uses authedFetchResponse instead of + // authedFetch — NDJSON streaming needs the raw Response so we can + // iterate `res.body` one chunk at a time. + const res = await authedFetchResponse( + `/api/parse-835?${params.toString()}`, + { + method: "POST", + body: form, + headers: { Accept: accept }, + } + ); if (!res.ok) { const detail = await readErrorBody(res); @@ -362,16 +375,7 @@ async function parse999( if (!isConfigured) throw notConfiguredError(); const form = new FormData(); form.append("file", file, file.name); - const res = await fetch(joinUrl("/api/parse-999"), { - method: "POST", - body: form, - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - const body = (await res.json()) as { + const body = await authedFetch<{ ack: { id: number; accepted_count: number; @@ -382,7 +386,14 @@ async function parse999( raw_999_text: string; }; parsed: unknown; - }; + }>("/api/parse-999", { + method: "POST", + body: form, + // Don't set Content-Type — the browser sets the multipart + // boundary when the body is FormData. authedFetch only adds a + // default Accept, which we override here. + headers: { Accept: "application/json" }, + }); return { ack: { id: body.ack.id, @@ -403,14 +414,14 @@ async function parse999( async function health(): Promise { if (!isConfigured) return null; - const res = await fetch(joinUrl("/api/health")); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); + // health() is best-effort — used by the side-scan ping. Don't let a + // 401 from authedFetch redirect the operator; just return null when + // anything goes wrong. + try { + return await authedFetch("/api/health"); + } catch { + return null; } - return (await res.json()) as HealthResponse; } // --------------------------------------------------------------------------- @@ -423,48 +434,26 @@ async function listBatches(limit?: number): Promise { if (!isConfigured) throw notConfiguredError(); const params: Record = {}; if (limit !== undefined) params.limit = limit; - const res = await fetch(joinUrl(`/api/batches${qs(params)}`), { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); - } - const body = (await res.json()) as { items: BatchSummary[] }; + const body = await authedFetch<{ items: BatchSummary[] }>( + `/api/batches${qs(params)}` + ); return body.items; } async function getBatch(id: string): Promise { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); - } - return (await res.json()) as ParseResult837 | ParseResult835; + return authedFetch( + `/api/batches/${encodeURIComponent(id)}` + ); } async function listClaims( params: ListClaimsParams ): Promise> { if (!isConfigured) throw notConfiguredError(); - const res = await fetch( - joinUrl(`/api/claims${qs(params as Record)}`), - { headers: { Accept: "application/json" } } + return authedFetch>( + `/api/claims${qs(params as Record)}` ); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); - } - return (await res.json()) as PaginatedResponse; } /** @@ -480,14 +469,31 @@ async function listClaims( */ async function getClaimDetail(id: string): Promise { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); + try { + return await authedFetch( + `/api/claims/${encodeURIComponent(id)}` + ); + } catch (err) { + // authedFetch throws the richer auth/api.ts ApiError (carries + // `status` + `code` + `detail`). The drawer code branches on + // `.status === 404` for the "claim doesn't exist" state, and + // existing callers use `instanceof ApiError` (the lib/api.ts one) + // to detect structured failures. Re-wrap into the lib/api.ts + // ApiError so both keep working unchanged. + if ( + err && + typeof err === "object" && + "status" in err && + typeof (err as { status: unknown }).status === "number" + ) { + const e = err as { status: number; code?: string; detail?: string }; + throw new ApiError( + e.status, + e.detail ?? e.code ?? "error" + ); + } + throw err; } - return (await res.json()) as ClaimDetail; } /** @@ -509,8 +515,14 @@ async function serializeClaim837( id: string, ): Promise<{ text: string; filename: string }> { if (!isConfigured) throw notConfiguredError(); - const res = await fetch( - joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`), + // authedFetchText gives us the body as a string with the same + // 401-redirect + error-shape behavior as authedFetch. We need the + // Response object for the Content-Disposition header, so this + // function delegates to authedFetchResponse and reads text + + // headers itself. No Accept negotiation — the endpoint always + // returns text/x12. + const res = await authedFetchResponse( + `/api/claims/${encodeURIComponent(id)}/serialize-837` ); if (!res.ok) { const detail = await readErrorBody(res); @@ -530,17 +542,9 @@ async function listRemittances( params: ListRemittancesParams ): Promise> { if (!isConfigured) throw notConfiguredError(); - const res = await fetch( - joinUrl(`/api/remittances${qs(params as Record)}`), - { headers: { Accept: "application/json" } } + return authedFetch>( + `/api/remittances${qs(params as Record)}` ); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); - } - return (await res.json()) as PaginatedResponse; } /** @@ -549,14 +553,7 @@ async function listRemittances( */ async function getRemittance(id: string): Promise { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - return (await res.json()) as T; + return authedFetch(`/api/remittances/${encodeURIComponent(id)}`); } /** @@ -580,34 +577,16 @@ async function getBatchDiff(a: string, b: string): Promise { if (typeof b !== "string" || b.length === 0) { throw new ApiError(400, "Missing param: ?b= is required."); } - const res = await fetch( - joinUrl( - `/api/batch-diff?${qs({ a, b })}`, - ), - { headers: { Accept: "application/json" } }, - ); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - return (await res.json()) as BatchDiff; + return authedFetch(`/api/batch-diff?${qs({ a, b })}`); } async function listProviders( params: ListProvidersParams = {} ): Promise> { if (!isConfigured) throw notConfiguredError(); - const res = await fetch( - joinUrl(`/api/providers${qs(params as Record)}`), - { headers: { Accept: "application/json" } } + return authedFetch>( + `/api/providers${qs(params as Record)}` ); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); - } - return (await res.json()) as PaginatedResponse; } /** @@ -674,17 +653,9 @@ async function listActivity( params: ListActivityParams = {} ): Promise> { if (!isConfigured) throw notConfiguredError(); - const res = await fetch( - joinUrl(`/api/activity${qs(params as Record)}`), - { headers: { Accept: "application/json" } } + return authedFetch>( + `/api/activity${qs(params as Record)}` ); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new Error( - `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` - ); - } - return (await res.json()) as PaginatedResponse; } // --------------------------------------------------------------------------- @@ -695,14 +666,7 @@ async function listActivity( async function listUnmatched(): Promise { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/reconciliation/unmatched`), { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - return (await res.json()) as UnmatchedResponse; + return authedFetch(`/api/reconciliation/unmatched`); } async function matchRemit( @@ -710,36 +674,20 @@ async function matchRemit( remitId: string ): Promise { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/reconciliation/match`), { + return authedFetch(`/api/reconciliation/match`, { method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claim_id: claimId, remit_id: remitId }), }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - return (await res.json()) as MatchResponse; } async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/reconciliation/unmatch`), { + return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, { method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claim_id: claimId }), }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - return (await res.json()) as { claim: UnmatchedClaim }; } // --------------------------------------------------------------------------- @@ -778,15 +726,12 @@ async function listAcks(params: { limit?: number } = {}): Promise = {}; if (params.limit !== undefined) query.limit = params.limit; - const res = await fetch( - joinUrl(`/api/acks${qs(query)}`), - { headers: { Accept: "application/json" } } - ); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - const body = (await res.json()) as { items: RawAckRow[]; total: number; returned: number; has_more: boolean }; + const body = await authedFetch<{ + items: RawAckRow[]; + total: number; + returned: number; + has_more: boolean; + }>(`/api/acks${qs(query)}`); return { items: body.items.map(mapAck), total: body.total, @@ -797,14 +742,9 @@ async function listAcks(params: { limit?: number } = {}): Promise { if (!isConfigured) throw notConfiguredError(); - const res = await fetch(joinUrl(`/api/acks/${encodeURIComponent(String(id))}`), { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - const detail = await readErrorBody(res); - throw new ApiError(res.status, detail || res.statusText); - } - const row = (await res.json()) as RawAckRow & { raw_json: unknown }; + const row = await authedFetch( + `/api/acks/${encodeURIComponent(String(id))}` + ); return { ...mapAck(row), rawJson: row.raw_json }; } diff --git a/src/main.tsx b/src/main.tsx index 6842311..dfffe3e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AuthProvider } from "@/auth/AuthProvider"; import App from "./App"; import "./index.css"; @@ -18,7 +19,13 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + {/* 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). */} + + +