feat(auth): fetch wrapper with 401 redirect + authApi
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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) ?? "";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user