Replace Axios with native fetch; add global 401 session-expiry redirect

All API calls now go through a thin request() wrapper around native fetch.
Removes the axios dependency entirely. The wrapper injects the JWT on every
request and — the key fix — clears localStorage and redirects to /login on
any 401 response, so expired sessions no longer leave users on broken pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 21:04:18 +02:00
parent c5976882be
commit 479108779f
7 changed files with 578 additions and 119 deletions
+21 -9
View File
@@ -22,7 +22,7 @@ frontend/
├── src/
│ ├── main.tsx ← React root, QueryClientProvider, BrowserRouter
│ ├── App.tsx ← Route tree, PrivateRoute, AdminRoute
│ ├── api/client.ts ← Axios instance + ALL API functions (single source of truth)
│ ├── api/client.ts ← Native fetch wrapper (`request()`) + ALL API functions (single source of truth); no Axios
│ ├── hooks/
│ │ ├── useAuth.ts ← Token state (localStorage), login/logout
│ │ └── useTheme.ts ← Theme toggle
@@ -86,21 +86,33 @@ frontend/
### API client (`src/api/client.ts`)
Single Axios instance — **all** API calls live here, nowhere else:
**No Axios** — uses a thin native `fetch` wrapper. All API calls live here, nowhere else.
The core `request()` function handles:
- Prepends `/api` base URL
- Injects `Authorization: Bearer {token}` from `localStorage` on every request
- **Global 401 handler**: clears `localStorage` token and redirects to `/login` via `window.location.href` — this is the expired-session redirect
- Throws `ApiError(status, detail)` on non-2xx responses (detail parsed from JSON body)
- Returns `undefined` on 204 No Content
- Supports `blob: true` for file download/preview responses
```typescript
const api = axios.create({ baseURL: "/api" });
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// The internal api object — use these methods in exported functions:
api.get<T>(path, params?) // GET with optional query params object
api.post<T>(path, json?) // POST with JSON body
api.postForm<T>(path, URLSearchParams) // POST with form-encoded body (login)
api.postFile<T>(path, FormData) // POST with multipart body (file upload)
api.patch<T>(path, json?) // PATCH with JSON body
api.delete<T>(path) // DELETE
api.getBlob(path) // GET → Blob (download / view)
```
Adding a new API call:
1. Define a TypeScript interface for the response if it's new.
2. Add a named export function (`getX`, `createX`, `updateX`, `deleteX`).
3. Use `api.get<T>(...)`, `api.post<T>(...)`, etc.; always `.then((r) => r.data)`.
3. Use the appropriate `api.*` method — return the promise directly (no `.then((r) => r.data)`).
Error handling in components: catch blocks receive an `ApiError` instance with `.status` and `.message` (the detail string).
### TanStack Query conventions
+3
View File
@@ -130,6 +130,8 @@ Display and edit personal information.
## API client (`src/api/client.ts`)
**No Axios** — replaced with a native `fetch` wrapper (`request()`). Global 401 handler clears the token and redirects to `/login`, fixing the expired-session blank-page bug. All exported function signatures are unchanged.
Key document-related functions:
| Function | Description |
@@ -199,6 +201,7 @@ Key document-related functions:
- [x] Document sharing UI (Sharing section + Shared with me view)
- [x] AI suggestion confirm/reject UI (folder + filename)
- [x] Groups admin UI
- [x] Replace Axios with native fetch; add global 401 → `/login` redirect for expired sessions
- [ ] Toast notification system
- [ ] Loading skeletons
- [ ] Cmd+K global search (`CommandDialog`)
+1 -2
View File
@@ -13,8 +13,7 @@
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.400.0",
"react": "^18.3.1",
+219 -101
View File
@@ -1,25 +1,126 @@
import axios from "axios";
const BASE = "/api";
const api = axios.create({ baseURL: "/api" });
// ---------------------------------------------------------------------------
// Core fetch wrapper
// ---------------------------------------------------------------------------
api.interceptors.request.use((config) => {
class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.status = status;
this.detail = detail;
}
}
async function request<T>(
method: string,
path: string,
options: {
json?: unknown;
form?: URLSearchParams;
body?: FormData;
params?: Record<string, string | number | undefined>;
blob?: boolean;
} = {}
): Promise<T> {
const { json, form, body, params, blob: asBlob } = options;
// Build URL with optional query params
let url = `${BASE}${path}`;
if (params) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") qs.set(k, String(v));
}
const str = qs.toString();
if (str) url += `?${str}`;
}
// Build headers
const headers: Record<string, string> = {};
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
if (token) headers["Authorization"] = `Bearer ${token}`;
if (json !== undefined) headers["Content-Type"] = "application/json";
if (form !== undefined) headers["Content-Type"] = "application/x-www-form-urlencoded";
// FormData: intentionally omit Content-Type so browser sets multipart boundary
// Build body
let requestBody: BodyInit | undefined;
if (json !== undefined) requestBody = JSON.stringify(json);
else if (form !== undefined) requestBody = form;
else if (body !== undefined) requestBody = body;
const response = await fetch(url, { method, headers, body: requestBody });
// Global 401 handler — expired or invalid token
if (response.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
// Throw so callers don't try to process the response
throw new ApiError(401, "Session expired");
}
// Parse error responses
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
if (typeof err.detail === "string") detail = err.detail;
else if (Array.isArray(err.detail)) detail = err.detail.map((e: { msg: string }) => e.msg).join(", ");
} catch {
// non-JSON error body — keep default message
}
throw new ApiError(response.status, detail);
}
// 204 No Content
if (response.status === 204) return undefined as T;
if (asBlob) return response.blob() as Promise<T>;
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
}
const api = {
get: <T>(path: string, params?: Record<string, string | number | undefined>) =>
request<T>("GET", path, { params }),
post: <T>(path: string, json?: unknown) =>
request<T>("POST", path, { json }),
postForm: <T>(path: string, form: URLSearchParams) =>
request<T>("POST", path, { form }),
postFile: <T>(path: string, body: FormData) =>
request<T>("POST", path, { body }),
patch: <T>(path: string, json?: unknown) =>
request<T>("PATCH", path, { json }),
delete: <T>(path: string) =>
request<T>("DELETE", path),
getBlob: (path: string) =>
request<Blob>("GET", path, { blob: true }),
};
export default api;
// --- Auth ---
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export const login = (email: string, password: string) =>
api
.post<{ access_token: string }>("/auth/login", new URLSearchParams({ username: email, password }))
.then((r) => r.data.access_token);
api.postForm<{ access_token: string }>(
"/auth/login",
new URLSearchParams({ username: email, password })
).then((r) => r.access_token);
export const register = (email: string, password: string, full_name?: string) =>
api.post("/auth/register", { email, password, full_name }).then((r) => r.data);
api.post("/auth/register", { email, password, full_name });
// ---------------------------------------------------------------------------
// Users
// ---------------------------------------------------------------------------
// --- Users ---
export interface UserData {
id: string;
email: string;
@@ -29,19 +130,21 @@ export interface UserData {
color_mode: string | null;
}
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
export const getMe = () => api.get<UserData>("/users/me");
export interface DashboardPrefs {
app_ids: string[];
}
export const getDashboardPrefs = () =>
api.get<DashboardPrefs>("/users/me/preferences").then((r) => r.data);
export const getDashboardPrefs = () => api.get<DashboardPrefs>("/users/me/preferences");
export const updateDashboardPrefs = (app_ids: string[]) =>
api.patch<DashboardPrefs>("/users/me/preferences", { app_ids }).then((r) => r.data);
api.patch<DashboardPrefs>("/users/me/preferences", { app_ids });
// ---------------------------------------------------------------------------
// Admin — Users
// ---------------------------------------------------------------------------
// --- Admin ---
export interface AdminUserCreate {
email: string;
password: string;
@@ -49,19 +152,21 @@ export interface AdminUserCreate {
is_admin?: boolean;
}
export const adminGetUsers = () =>
api.get<UserData[]>("/admin/users").then((r) => r.data);
export const adminGetUsers = () => api.get<UserData[]>("/admin/users");
export const adminCreateUser = (data: AdminUserCreate) =>
api.post<UserData>("/admin/users", data).then((r) => r.data);
api.post<UserData>("/admin/users", data);
export const adminDeleteUser = (userId: string) =>
api.delete(`/admin/users/${userId}`);
export const adminToggleActive = (userId: string) =>
api.patch<UserData>(`/admin/users/${userId}/active`).then((r) => r.data);
api.patch<UserData>(`/admin/users/${userId}/active`);
// ---------------------------------------------------------------------------
// Profile
// ---------------------------------------------------------------------------
// --- Profile ---
export interface ProfileData {
id: string;
user_id: string;
@@ -79,13 +184,15 @@ export interface ProfileUpdate {
address?: string | null;
}
export const getProfile = () =>
api.get<ProfileData>("/profile/me").then((r) => r.data);
export const getProfile = () => api.get<ProfileData>("/profile/me");
export const updateProfile = (data: ProfileUpdate) =>
api.put<ProfileData>("/profile/me", data).then((r) => r.data);
api.patch<ProfileData>("/profile/me", data);
// ---------------------------------------------------------------------------
// Documents
// ---------------------------------------------------------------------------
// --- Documents ---
export type DocumentStatus = "pending" | "processing" | "done" | "failed";
export interface CategoryOut {
@@ -154,41 +261,40 @@ export interface DocumentStatusOut {
}
export const listDocuments = (params: DocumentListParams = {}) =>
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
api.get<DocumentPage>("/documents", params as Record<string, string | number | undefined>);
export const listSharedWithMe = (params: DocumentListParams = {}) =>
api.get<DocumentPage>("/documents/shared-with-me", { params }).then((r) => r.data);
api.get<DocumentPage>("/documents/shared-with-me", params as Record<string, string | number | undefined>);
export const getDocumentShares = (docId: string) =>
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`).then((r) => r.data);
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`);
export const addDocumentShare = (docId: string, groupId: string) =>
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId }).then((r) => r.data);
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId });
export const removeDocumentShare = (docId: string, groupId: string) =>
api.delete(`/documents/${docId}/shares/${groupId}`);
export const getDocument = (id: string) =>
api.get<DocumentOut>(`/documents/${id}`).then((r) => r.data);
api.get<DocumentOut>(`/documents/${id}`);
export const getDocumentStatus = (id: string) =>
api.get<DocumentStatusOut>(`/documents/${id}/status`).then((r) => r.data);
api.get<DocumentStatusOut>(`/documents/${id}/status`);
export const uploadDocument = (file: File) => {
const form = new FormData();
form.append("file", file);
return api.post<DocumentOut>("/documents/upload", form).then((r) => r.data);
return api.postFile<DocumentOut>("/documents/upload", form);
};
export const updateDocumentType = (id: string, document_type: string) =>
api.patch<DocumentOut>(`/documents/${id}/type`, { document_type }).then((r) => r.data);
api.patch<DocumentOut>(`/documents/${id}/type`, { document_type });
export const deleteDocument = (id: string) =>
api.delete(`/documents/${id}`);
export const deleteDocument = (id: string) => api.delete(`/documents/${id}`);
export const downloadDocument = async (id: string, filename: string) => {
const response = await api.get(`/documents/${id}/file`, { responseType: "blob" });
const url = URL.createObjectURL(response.data);
const blob = await api.getBlob(`/documents/${id}/file`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
@@ -197,22 +303,21 @@ export const downloadDocument = async (id: string, filename: string) => {
};
export const viewDocument = async (id: string): Promise<void> => {
const response = await api.get(`/documents/${id}/file`, { responseType: "blob" });
const url = URL.createObjectURL(response.data);
const blob = await api.getBlob(`/documents/${id}/file`);
const url = URL.createObjectURL(blob);
const win = window.open(url, "_blank");
// Revoke after a generous delay — the new tab needs time to load the blob
setTimeout(() => URL.revokeObjectURL(url), 60_000);
if (!win) alert("Pop-up blocked. Please allow pop-ups for this site to preview PDFs.");
};
export const updateDocumentTags = (id: string, tags: string[]) =>
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags }).then((r) => r.data);
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags });
export const updateDocumentTitle = (id: string, title: string) =>
api.patch<DocumentOut>(`/documents/${id}/title`, { title }).then((r) => r.data);
api.patch<DocumentOut>(`/documents/${id}/title`, { title });
export const reprocessDocument = (id: string) =>
api.post<DocumentOut>(`/documents/${id}/reprocess`).then((r) => r.data);
api.post<DocumentOut>(`/documents/${id}/reprocess`);
export const assignCategory = (docId: string, catId: string) =>
api.post(`/documents/${docId}/categories/${catId}`);
@@ -220,20 +325,24 @@ export const assignCategory = (docId: string, catId: string) =>
export const removeCategory = (docId: string, catId: string) =>
api.delete(`/documents/${docId}/categories/${catId}`);
// --- Categories ---
export const listCategories = () =>
api.get<CategoryOut[]>("/documents/categories").then((r) => r.data);
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
export const listCategories = () => api.get<CategoryOut[]>("/documents/categories");
export const createCategory = (name: string) =>
api.post<CategoryOut>("/documents/categories", { name }).then((r) => r.data);
api.post<CategoryOut>("/documents/categories", { name });
export const renameCategory = (id: string, name: string) =>
api.patch<CategoryOut>(`/documents/categories/${id}`, { name }).then((r) => r.data);
api.patch<CategoryOut>(`/documents/categories/${id}`, { name });
export const deleteCategory = (id: string) =>
api.delete(`/documents/categories/${id}`);
export const deleteCategory = (id: string) => api.delete(`/documents/categories/${id}`);
// ---------------------------------------------------------------------------
// Appearance & Themes
// ---------------------------------------------------------------------------
// --- Appearance & Themes ---
export interface ThemeColors {
primary: string;
primary_hover: string;
@@ -259,31 +368,31 @@ export interface AppearanceSettings {
default_mode: string;
}
export const getAppearanceSettings = (): Promise<AppearanceSettings> =>
api.get<AppearanceSettings>("/settings/appearance").then((r) => r.data);
export const getAppearanceSettings = () =>
api.get<AppearanceSettings>("/settings/appearance");
export const updateAppearanceSettings = (data: AppearanceSettings): Promise<AppearanceSettings> =>
api.patch<AppearanceSettings>("/settings/appearance", data).then((r) => r.data);
export const updateAppearanceSettings = (data: AppearanceSettings) =>
api.patch<AppearanceSettings>("/settings/appearance", data);
export const getThemes = (): Promise<ThemeDefinition[]> =>
api.get<ThemeDefinition[]>("/settings/themes").then((r) => r.data);
export const getThemes = () => api.get<ThemeDefinition[]>("/settings/themes");
export const createTheme = (data: Omit<ThemeDefinition, "builtin">): Promise<ThemeDefinition> =>
api.post<ThemeDefinition>("/settings/themes", data).then((r) => r.data);
export const createTheme = (data: Omit<ThemeDefinition, "builtin">) =>
api.post<ThemeDefinition>("/settings/themes", data);
export const updateTheme = (
id: string,
data: { label?: string; light?: ThemeColors; dark?: ThemeColors }
): Promise<ThemeDefinition> =>
api.patch<ThemeDefinition>(`/settings/themes/${id}`, data).then((r) => r.data);
) => api.patch<ThemeDefinition>(`/settings/themes/${id}`, data);
export const deleteTheme = (id: string): Promise<void> =>
api.delete(`/settings/themes/${id}`).then((r) => r.data);
export const deleteTheme = (id: string) => api.delete(`/settings/themes/${id}`);
export const updateColorMode = (color_mode: string): Promise<UserData> =>
api.patch<UserData>("/users/me/color-mode", { color_mode }).then((r) => r.data);
export const updateColorMode = (color_mode: string) =>
api.patch<UserData>("/users/me/color-mode", { color_mode });
// ---------------------------------------------------------------------------
// Settings (admin only)
// ---------------------------------------------------------------------------
// --- Settings (admin only) ---
export interface AIProviderUpdate {
provider: string;
anthropic_api_key?: string;
@@ -297,35 +406,38 @@ export interface AIProviderUpdate {
}
export const getAISettings = () =>
api.get<Record<string, unknown>>("/settings/ai").then((r) => r.data);
api.get<Record<string, unknown>>("/settings/ai");
export const updateAISettings = (data: AIProviderUpdate) =>
api.patch<Record<string, unknown>>("/settings/ai", data).then((r) => r.data);
api.patch<Record<string, unknown>>("/settings/ai", data);
export const testAIConnection = () =>
api.post<{ ok: boolean; provider: string; response?: string; error?: string }>(
"/settings/ai/test"
).then((r) => r.data);
export const updateDocumentLimits = (max_pdf_mb: number) =>
api.patch<Record<string, unknown>>("/settings/documents/limits", { max_pdf_mb }).then(
(r) => r.data
);
export const getDocumentLimits = () =>
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data);
export const updateDocumentLimits = (max_pdf_mb: number) =>
api.patch<Record<string, unknown>>("/settings/documents/limits", { max_pdf_mb });
export const getDocumentLimits = () =>
api.get<Record<string, unknown>>("/settings/documents/limits");
// ---------------------------------------------------------------------------
// User groups (current user's own memberships)
// ---------------------------------------------------------------------------
// --- User groups (current user's own memberships) ---
export interface UserGroupOut {
id: string;
name: string;
description: string | null;
}
export const getMyGroups = () =>
api.get<UserGroupOut[]>("/users/me/groups").then((r) => r.data);
export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups");
// ---------------------------------------------------------------------------
// Groups (admin only)
// ---------------------------------------------------------------------------
// --- Groups (admin only) ---
export interface GroupOut {
id: string;
name: string;
@@ -356,17 +468,16 @@ export interface GroupUpdate {
description?: string | null;
}
export const adminListGroups = () =>
api.get<GroupOut[]>("/admin/groups").then((r) => r.data);
export const adminListGroups = () => api.get<GroupOut[]>("/admin/groups");
export const adminCreateGroup = (data: GroupCreate) =>
api.post<GroupOut>("/admin/groups", data).then((r) => r.data);
api.post<GroupOut>("/admin/groups", data);
export const adminGetGroup = (groupId: string) =>
api.get<GroupDetailOut>(`/admin/groups/${groupId}`).then((r) => r.data);
api.get<GroupDetailOut>(`/admin/groups/${groupId}`);
export const adminUpdateGroup = (groupId: string, data: GroupUpdate) =>
api.patch<GroupOut>(`/admin/groups/${groupId}`, data).then((r) => r.data);
api.patch<GroupOut>(`/admin/groups/${groupId}`, data);
export const adminDeleteGroup = (groupId: string) =>
api.delete(`/admin/groups/${groupId}`);
@@ -377,7 +488,10 @@ export const adminAddGroupMember = (groupId: string, userId: string) =>
export const adminRemoveGroupMember = (groupId: string, userId: string) =>
api.delete(`/admin/groups/${groupId}/members/${userId}`);
// --- Services ---
// ---------------------------------------------------------------------------
// Services
// ---------------------------------------------------------------------------
export interface ServiceStatus {
id: string;
name: string;
@@ -387,10 +501,12 @@ export interface ServiceStatus {
settings_path: string;
}
export const getServices = () =>
api.get<ServiceStatus[]>("/services").then((r) => r.data);
export const getServices = () => api.get<ServiceStatus[]>("/services");
// ---------------------------------------------------------------------------
// System Prompts (admin only)
// ---------------------------------------------------------------------------
// --- System Prompts (admin only) ---
export interface ServiceSystemPrompt {
label: string;
system: string;
@@ -402,17 +518,17 @@ export interface ServiceSystemPrompt {
export type SystemPromptsData = Record<string, ServiceSystemPrompt>;
export const getSystemPrompts = () =>
api.get<SystemPromptsData>("/settings/system-prompts").then((r) => r.data);
api.get<SystemPromptsData>("/settings/system-prompts");
export const updateSystemPrompt = (
serviceId: string,
data: { system: string; user_template: string }
) =>
api
.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data)
.then((r) => r.data);
) => api.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data);
// ---------------------------------------------------------------------------
// Document suggestions
// ---------------------------------------------------------------------------
// --- Document suggestions (watch-ingested documents) ---
export const confirmFolderSuggestion = (docId: string) =>
api.post(`/documents/${docId}/suggestions/folder/confirm`);
@@ -425,7 +541,10 @@ export const confirmFilenameSuggestion = (docId: string) =>
export const rejectFilenameSuggestion = (docId: string) =>
api.post(`/documents/${docId}/suggestions/filename/reject`);
// --- Plugins ---
// ---------------------------------------------------------------------------
// Plugins
// ---------------------------------------------------------------------------
export interface PluginOut {
id: string;
name: string;
@@ -456,14 +575,13 @@ export interface PluginManifest {
};
}
export const getPlugins = () =>
api.get<PluginOut[]>("/plugins").then((r) => r.data);
export const getPlugins = () => api.get<PluginOut[]>("/plugins");
export const getPluginManifest = (id: string) =>
api.get<PluginManifest>(`/plugins/${id}/manifest`).then((r) => r.data);
api.get<PluginManifest>(`/plugins/${id}/manifest`);
export const getPluginSettings = (id: string) =>
api.get<Record<string, unknown>>(`/plugins/${id}/settings`).then((r) => r.data);
api.get<Record<string, unknown>>(`/plugins/${id}/settings`);
export const updatePluginSettings = (id: string, data: Record<string, unknown>) =>
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data).then((r) => r.data);
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data);
+2 -7
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { useState } from "react";
import { Briefcase, LayoutDashboard } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -22,12 +21,8 @@ export default function LoginPage() {
try {
await login(email, password);
} catch (err) {
if (axios.isAxiosError(err)) {
const detail = err.response?.data?.detail;
setError(typeof detail === "string" ? detail : "Login failed.");
} else {
setError("Login failed.");
}
const detail = err instanceof Error ? err.message : "Login failed.";
setError(detail);
}
};