feat(auth): wire AuthProvider + RequireAuth into app shell
This commit is contained in:
+10
-1
@@ -12,6 +12,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 (
|
||||
@@ -26,7 +28,14 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<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 path="claims" element={<Claims />} />
|
||||
<Route path="remittances" element={<Remittances />} />
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -88,6 +88,54 @@ export async function authedFetch<T = unknown>(
|
||||
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
|
||||
// they bypass the 401-redirect behavior on purpose — a 401 from
|
||||
|
||||
+106
-174
@@ -42,6 +42,10 @@ import type {
|
||||
UnmatchedResponse,
|
||||
BatchSummary as ParserBatchSummary,
|
||||
} from "@/types";
|
||||
import {
|
||||
authedFetch,
|
||||
authedFetchResponse,
|
||||
} from "@/auth/api";
|
||||
|
||||
/**
|
||||
* Base URL the SPA uses for API calls. Comes from `VITE_API_BASE_URL` at
|
||||
@@ -193,10 +197,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();
|
||||
@@ -310,11 +310,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);
|
||||
@@ -346,11 +353,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);
|
||||
@@ -376,16 +389,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;
|
||||
@@ -396,7 +400,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,
|
||||
@@ -417,14 +428,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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -437,48 +448,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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -494,14 +483,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -523,8 +529,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);
|
||||
@@ -544,17 +556,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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,14 +567,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)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,51 +581,25 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||
*/
|
||||
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
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>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,15 +619,7 @@ async function getDashboardSummary(
|
||||
if (params.months !== undefined) query.months = params.months;
|
||||
if (params.topProviders !== undefined) query.top_providers = params.topProviders;
|
||||
if (params.denials !== undefined) query.denials = params.denials;
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/dashboard/summary${qs(query)}`),
|
||||
{ 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 DashboardSummary;
|
||||
return authedFetch<DashboardSummary>(`/api/dashboard/summary${qs(query)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -667,14 +630,7 @@ async function getDashboardSummary(
|
||||
|
||||
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(
|
||||
@@ -682,36 +638,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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -750,15 +690,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,
|
||||
@@ -769,14 +706,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 };
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -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(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<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>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user