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
+105 -165
View File
@@ -43,6 +43,10 @@ import type {
UnmatchedResponse,
BatchSummary as ParserBatchSummary,
} from "@/types";
import {
authedFetch,
authedFetchResponse,
} from "@/auth/api";
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
@@ -167,10 +171,6 @@ export class ApiError extends Error {
}
}
function joinUrl(path: string): string {
return `${BASE_URL.replace(/\/$/, "")}${path}`;
}
async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
@@ -296,11 +296,18 @@ async function parse837(
form.append("file", file, file.name);
const accept = onProgress ? "application/x-ndjson" : "application/json";
const res = await fetch(joinUrl(`/api/parse-837?${params.toString()}`), {
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<HealthResponse | null> {
if (!isConfigured) return null;
const res = await fetch(joinUrl("/api/health"));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
// health() is best-effort — used by the side-scan ping. Don't let a
// 401 from authedFetch redirect the operator; just return null when
// anything goes wrong.
try {
return await authedFetch<HealthResponse>("/api/health");
} catch {
return null;
}
return (await res.json()) as HealthResponse;
}
// ---------------------------------------------------------------------------
@@ -423,48 +434,26 @@ async function listBatches(limit?: number): Promise<BatchSummary[]> {
if (!isConfigured) throw notConfiguredError();
const params: Record<string, unknown> = {};
if (limit !== undefined) params.limit = limit;
const res = await fetch(joinUrl(`/api/batches${qs(params)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
const body = (await res.json()) as { items: BatchSummary[] };
const body = await authedFetch<{ items: BatchSummary[] }>(
`/api/batches${qs(params)}`
);
return body.items;
}
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as ParseResult837 | ParseResult835;
return authedFetch<ParseResult837 | ParseResult835>(
`/api/batches/${encodeURIComponent(id)}`
);
}
async function listClaims<T = unknown>(
params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/claims${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
/**
@@ -480,14 +469,31 @@ async function listClaims<T = unknown>(
*/
async function getClaimDetail(id: string): Promise<ClaimDetail> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
try {
return await authedFetch<ClaimDetail>(
`/api/claims/${encodeURIComponent(id)}`
);
} catch (err) {
// authedFetch throws the richer auth/api.ts ApiError (carries
// `status` + `code` + `detail`). The drawer code branches on
// `.status === 404` for the "claim doesn't exist" state, and
// existing callers use `instanceof ApiError` (the lib/api.ts one)
// to detect structured failures. Re-wrap into the lib/api.ts
// ApiError so both keep working unchanged.
if (
err &&
typeof err === "object" &&
"status" in err &&
typeof (err as { status: unknown }).status === "number"
) {
const e = err as { status: number; code?: string; detail?: string };
throw new ApiError(
e.status,
e.detail ?? e.code ?? "error"
);
}
throw err;
}
return (await res.json()) as ClaimDetail;
}
/**
@@ -509,8 +515,14 @@ async function serializeClaim837(
id: string,
): Promise<{ text: string; filename: string }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
// authedFetchText gives us the body as a string with the same
// 401-redirect + error-shape behavior as authedFetch. We need the
// Response object for the Content-Disposition header, so this
// function delegates to authedFetchResponse and reads text +
// headers itself. No Accept negotiation — the endpoint always
// returns text/x12.
const res = await authedFetchResponse(
`/api/claims/${encodeURIComponent(id)}/serialize-837`
);
if (!res.ok) {
const detail = await readErrorBody(res);
@@ -530,17 +542,9 @@ async function listRemittances<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/remittances${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
/**
@@ -549,14 +553,7 @@ async function listRemittances<T = unknown>(
*/
async function getRemittance<T = unknown>(id: string): Promise<T> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as T;
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
}
/**
@@ -580,34 +577,16 @@ async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
const res = await fetch(
joinUrl(
`/api/batch-diff?${qs({ a, b })}`,
),
{ headers: { Accept: "application/json" } },
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as BatchDiff;
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
}
async function listProviders<T = unknown>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/providers${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
/**
@@ -674,17 +653,9 @@ async function listActivity<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/activity${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
// ---------------------------------------------------------------------------
@@ -695,14 +666,7 @@ async function listActivity<T = unknown>(
async function listUnmatched(): Promise<UnmatchedResponse> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/reconciliation/unmatched`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as UnmatchedResponse;
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
}
async function matchRemit(
@@ -710,36 +674,20 @@ async function matchRemit(
remitId: string
): Promise<MatchResponse> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/reconciliation/match`), {
return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as MatchResponse;
}
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/reconciliation/unmatch`), {
return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId }),
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as { claim: UnmatchedClaim };
}
// ---------------------------------------------------------------------------
@@ -778,15 +726,12 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
if (!isConfigured) throw notConfiguredError();
const query: Record<string, unknown> = {};
if (params.limit !== undefined) query.limit = params.limit;
const res = await fetch(
joinUrl(`/api/acks${qs(query)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const body = (await res.json()) as { items: RawAckRow[]; total: number; returned: number; has_more: boolean };
const body = await authedFetch<{
items: RawAckRow[];
total: number;
returned: number;
has_more: boolean;
}>(`/api/acks${qs(query)}`);
return {
items: body.items.map(mapAck),
total: body.total,
@@ -797,14 +742,9 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/acks/${encodeURIComponent(String(id))}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const row = (await res.json()) as RawAckRow & { raw_json: unknown };
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
`/api/acks/${encodeURIComponent(String(id))}`
);
return { ...mapAck(row), rawJson: row.raw_json };
}