feat: SAC 0.7.3 custom error pages 401/403/404/429

Centered logo pages with event explanation and allowed actions; router
and apiFetch redirect to them on auth, permission, not-found, and rate-limit errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-01 11:06:21 +10:00
parent d3a337992c
commit 141fc44099
9 changed files with 241 additions and 16 deletions
+34 -7
View File
@@ -31,29 +31,56 @@ export function clearToken(): void {
localStorage.removeItem(ROLE_KEY);
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function redirectToErrorPage(status: 401 | 403 | 429): void {
window.location.href = `/${status}`;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
const hadToken = !!token;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(path, { ...init, headers });
const text = await res.text();
if (res.status === 401) {
clearToken();
window.location.href = "/login";
throw new Error("Unauthorized");
if (hadToken) {
clearToken();
redirectToErrorPage(401);
}
throw new ApiError(parseApiError(text) || "Unauthorized", 401);
}
if (res.status === 403) {
throw new Error("Forbidden");
if (hadToken) {
redirectToErrorPage(403);
}
throw new ApiError(parseApiError(text) || "Forbidden", 403);
}
if (res.status === 429) {
redirectToErrorPage(429);
throw new ApiError(parseApiError(text) || "Too many requests", 429);
}
if (!res.ok) {
const text = await res.text();
throw new Error(parseApiError(text) || res.statusText);
throw new ApiError(parseApiError(text) || res.statusText, res.status);
}
return res.json() as Promise<T>;
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export interface TokenResponse {