feat(auth): wire AuthProvider + RequireAuth into app shell
This commit is contained in:
+10
-1
@@ -13,6 +13,8 @@ import { Acks } from "@/pages/Acks";
|
|||||||
import { Batches } from "@/pages/Batches";
|
import { Batches } from "@/pages/Batches";
|
||||||
import { BatchDiff } from "@/pages/BatchDiff";
|
import { BatchDiff } from "@/pages/BatchDiff";
|
||||||
import Inbox from "@/pages/Inbox";
|
import Inbox from "@/pages/Inbox";
|
||||||
|
import { Login } from "@/pages/Login";
|
||||||
|
import { RequireAuth } from "@/auth/RequireAuth";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +29,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<DrillStackProvider>
|
<DrillStackProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
{/* /login sits OUTSIDE the auth-gated Layout so an
|
||||||
|
unauthenticated operator can reach the sign-in screen. */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
{/* Every other route inherits the auth gate via RequireAuth
|
||||||
|
wrapping the Layout outlet. Authenticated operators see
|
||||||
|
the full app; unauthenticated ones are bounced back here
|
||||||
|
with `?next=<current>`. */}
|
||||||
|
<Route element={<RequireAuth><Layout /></RequireAuth>}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="claims" element={<Claims />} />
|
<Route path="claims" element={<Claims />} />
|
||||||
<Route path="remittances" element={<Remittances />} />
|
<Route path="remittances" element={<Remittances />} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("auth/api fetch wrapper", () => {
|
|||||||
statusText: "Unauthorized",
|
statusText: "Unauthorized",
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
json: async () => ({ error: "session_expired" }),
|
json: async () => ({ error: "session_expired" }),
|
||||||
} as Response);
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
// Stub window.location.href setter to capture navigation without
|
// Stub window.location.href setter to capture navigation without
|
||||||
// actually navigating (jsdom does not implement location.href assignment).
|
// actually navigating (jsdom does not implement location.href assignment).
|
||||||
@@ -54,7 +54,7 @@ describe("auth/api fetch wrapper", () => {
|
|||||||
statusText: "Forbidden",
|
statusText: "Forbidden",
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
json: async () => ({ error: "forbidden" }),
|
json: async () => ({ error: "forbidden" }),
|
||||||
} as Response);
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
Object.defineProperty(window, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get: () => ({
|
get: () => ({
|
||||||
@@ -75,7 +75,7 @@ describe("auth/api fetch wrapper", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
json: async () => ({ hello: "world" }),
|
json: async () => ({ hello: "world" }),
|
||||||
} as Response);
|
} as unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
const { authedFetch } = await import("./api");
|
const { authedFetch } = await import("./api");
|
||||||
const data = await authedFetch("/api/anything");
|
const data = await authedFetch("/api/anything");
|
||||||
expect(data).toEqual({ hello: "world" });
|
expect(data).toEqual({ hello: "world" });
|
||||||
|
|||||||
@@ -88,6 +88,54 @@ export async function authedFetch<T = unknown>(
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `authedFetch` but returns the response body as text instead of
|
||||||
|
* parsed JSON. Used by `serializeClaim837` (the SP8 endpoint returns
|
||||||
|
* `text/x12`, not JSON). Same 401-redirect + error-shape behavior as
|
||||||
|
* the JSON variant.
|
||||||
|
*/
|
||||||
|
export async function authedFetchText(path: string, init?: RequestInit): Promise<string> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new ApiError(401, "session_expired");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* no body */
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
body?.error ?? "error",
|
||||||
|
body?.detail ?? res.statusText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `authedFetch` but returns the raw `Response` so the caller can
|
||||||
|
* stream the body (NDJSON). Used by `parse837` / `parse835`. 401 still
|
||||||
|
* redirects; the caller is responsible for reading the body.
|
||||||
|
*/
|
||||||
|
export async function authedFetchResponse(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new ApiError(401, "session_expired");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
|
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
|
||||||
// they bypass the 401-redirect behavior on purpose — a 401 from
|
// they bypass the 401-redirect behavior on purpose — a 401 from
|
||||||
|
|||||||
+105
-165
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user