e10d3886c2
Single source of truth for the VITE_API_BASE_URL prefix logic. Both auth/api and lib/api imported the same BASE_URL const; promote joinUrl to a shared exported helper so future endpoints don't drift on the trailing-slash normalization.
180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
/**
|
|
* Auth-aware fetch wrapper.
|
|
*
|
|
* Every authenticated backend call in the SPA goes through `authedFetch`.
|
|
* Responsibilities:
|
|
*
|
|
* 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly
|
|
* cookie rides along on cross-origin XHR calls.
|
|
* 2. Tag every request with `Accept: application/json` by default so
|
|
* the FastAPI backend knows to return its structured error shape
|
|
* (`{"error": "...", "detail": "..."}`) on failures.
|
|
* 3. On a 401 from anything OTHER than the auth endpoints themselves,
|
|
* redirect to `/login?next=<current>` so the operator sees a real
|
|
* sign-in screen instead of an infinite stream of failing queries.
|
|
* 401 from `/api/auth/*` is the normal "bad password" path — let
|
|
* the caller handle it.
|
|
* 4. On any other non-2xx, parse the JSON body and throw an `ApiError`
|
|
* carrying `status`, the backend's `error` code, and the `detail`
|
|
* string. The Login page and hooks both branch on `.code`.
|
|
* 5. On 204, return `undefined` (so callers can `await` without
|
|
* blowing up on `res.json()` of an empty body).
|
|
* 6. Otherwise return the parsed JSON.
|
|
*
|
|
* `authApi` is the typed wrapper around the three auth endpoints
|
|
* (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`).
|
|
*/
|
|
|
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
|
|
|
export function joinUrl(path: string): string {
|
|
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Error thrown for any non-2xx `authedFetch` response. Carries the HTTP
|
|
* `status`, the backend's `error` code (so callers can branch on
|
|
* `err.code === "invalid_credentials"`, etc.), and the optional `detail`
|
|
* string for surfacing in toasts.
|
|
*
|
|
* Distinct from the `ApiError` in `src/lib/api.ts` — that one only
|
|
* carries `status` and is used by the existing pages; this one is the
|
|
* richer auth-aware variant that the Login page + hooks depend on.
|
|
*/
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
code: string;
|
|
detail?: string;
|
|
constructor(status: number, code: string, detail?: string) {
|
|
super(detail ?? code);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = code;
|
|
this.detail = detail;
|
|
}
|
|
}
|
|
|
|
function redirectToLogin() {
|
|
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = `/login?next=${next}`;
|
|
}
|
|
|
|
export async function authedFetch<T = unknown>(
|
|
path: string,
|
|
init?: RequestInit
|
|
): Promise<T> {
|
|
const res = await fetch(joinUrl(path), {
|
|
credentials: "include",
|
|
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
|
...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 */
|
|
}
|
|
const code = body?.error ?? "error";
|
|
const detail = body?.detail ?? res.statusText;
|
|
throw new ApiError(res.status, code, detail);
|
|
}
|
|
if (res.status === 204) return undefined 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
|
|
// they bypass the 401-redirect behavior on purpose — a 401 from
|
|
// /api/auth/login is "wrong password", not "session expired".
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const authApi = {
|
|
async login(username: string, password: string) {
|
|
const res = await fetch(joinUrl("/api/auth/login"), {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new ApiError(
|
|
res.status,
|
|
body.error ?? "error",
|
|
body.detail ?? res.statusText
|
|
);
|
|
}
|
|
return res.json();
|
|
},
|
|
async me() {
|
|
return authedFetch("/api/auth/me");
|
|
},
|
|
async logout() {
|
|
// Fire-and-forget. A network error here is fine — the server has
|
|
// already cleared the cookie or the operator is signing out because
|
|
// they're about to be disconnected. Either way, the client should
|
|
// drop the user into the unauthenticated state.
|
|
await fetch(joinUrl("/api/auth/logout"), {
|
|
method: "POST",
|
|
credentials: "include",
|
|
}).catch(() => undefined);
|
|
},
|
|
};
|