Add auth expiry interceptor and session-expired redirect

This commit is contained in:
2026-04-19 20:31:49 +02:00
parent d0991e0d40
commit cc8c71906f
8 changed files with 164 additions and 1 deletions

View File

@@ -0,0 +1,52 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { ApiError, get, isAuthError, SESSION_EXPIRED_EVENT } from "../client";
describe("api/client", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("dispatches session-expired for 401 responses", async () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: vi.fn().mockResolvedValue("Unauthorized"),
});
await expect(get("/test")).rejects.toBeInstanceOf(ApiError);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const call = dispatchSpy.mock.calls[0];
expect(call).toBeDefined();
const event = call?.[0] as Event;
expect(event.type).toBe(SESSION_EXPIRED_EVENT);
});
it("dispatches session-expired for 403 responses", async () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: vi.fn().mockResolvedValue("Forbidden"),
});
await expect(get("/test")).rejects.toBeInstanceOf(ApiError);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const call = dispatchSpy.mock.calls[0];
expect(call).toBeDefined();
const event = call?.[0] as Event;
expect(event.type).toBe(SESSION_EXPIRED_EVENT);
});
it("does not treat non-auth errors as auth errors", () => {
const error = new ApiError(500, "Server error");
expect(isAuthError(error)).toBe(false);
});
it("recognizes 401 and 403 ApiError as auth errors", () => {
expect(isAuthError(new ApiError(401, "Unauthorized"))).toBe(true);
expect(isAuthError(new ApiError(403, "Forbidden"))).toBe(true);
});
});

View File

@@ -39,6 +39,18 @@ export class ApiError extends Error {
}
}
/** Custom event name emitted when the backend signals an expired or invalid session. */
export const SESSION_EXPIRED_EVENT = "bangui:session-expired";
/**
* Returns `true` when the error represents an expired or unauthorized session.
*
* @param err - The error returned from the API client.
*/
export function isAuthError(err: unknown): err is ApiError {
return err instanceof ApiError && (err.status === 401 || err.status === 403);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -63,6 +75,13 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
if (!response.ok) {
const body: string = await response.text();
if (response.status === 401 || response.status === 403) {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
}
}
throw new ApiError(response.status, body);
}