feat(auth): wire AuthProvider + RequireAuth into app shell

This commit is contained in:
Nora
2026-06-22 15:16:52 -06:00
parent 3d0c7766f0
commit cb87456575
5 changed files with 174 additions and 170 deletions
+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 />} />
+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
+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>