From c1a7011d718d87a3ac7c5d32724d6ff6bf1da69f Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sat, 21 Feb 2026 15:03:13 -0300 Subject: [PATCH] frontend: apply bearer token to centralized API requests --- doc/operations-and-configuration.md | 5 ++ docker-compose.yml | 1 + frontend/src/lib/api.ts | 71 +++++++++++++++++++++-------- frontend/tsconfig.tsbuildinfo | 1 + 4 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 frontend/tsconfig.tsbuildinfo diff --git a/doc/operations-and-configuration.md b/doc/operations-and-configuration.md index 9243ede..bfad832 100644 --- a/doc/operations-and-configuration.md +++ b/doc/operations-and-configuration.md @@ -101,6 +101,11 @@ Selected defaults from `Settings` (`backend/app/core/config.py`): Frontend runtime API target: - `VITE_API_BASE` in `docker-compose.yml` frontend service +- `VITE_API_TOKEN` in `docker-compose.yml` frontend service (defaults to `USER_API_TOKEN` in compose, override to `ADMIN_API_TOKEN` when admin-only routes are needed) + +Frontend API authentication behavior: +- `frontend/src/lib/api.ts` adds `Authorization: Bearer ` for all API requests only when `VITE_API_TOKEN` is non-empty +- requests are still sent without authorization when `VITE_API_TOKEN` is unset, which keeps unauthenticated endpoints such as `/api/v1/health` backward-compatible Frontend container runtime behavior: - the container runs as non-root `node` diff --git a/docker-compose.yml b/docker-compose.yml index 57bb3a2..2254284 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,6 +112,7 @@ services: context: ./frontend environment: VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000/api/v1} + VITE_API_TOKEN: ${VITE_API_TOKEN:-${USER_API_TOKEN:-}} ports: - "${HOST_BIND_IP:-127.0.0.1}:5173:5173" volumes: diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 79b12ba..71d3c8b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -18,6 +18,39 @@ import type { */ const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/v1'; +/** + * Optional bearer token used for authenticated backend routes. + */ +const API_TOKEN = import.meta.env.VITE_API_TOKEN?.trim(); + +type ApiRequestInit = Omit & { headers?: HeadersInit }; + +/** + * Merges request headers and appends bearer authorization when configured. + */ +function buildRequestHeaders(headers?: HeadersInit): Headers | undefined { + if (!API_TOKEN && !headers) { + return undefined; + } + + const requestHeaders = new Headers(headers); + if (API_TOKEN) { + requestHeaders.set('Authorization', `Bearer ${API_TOKEN}`); + } + return requestHeaders; +} + +/** + * Executes an API request with centralized auth-header handling. + */ +function apiRequest(input: string, init: ApiRequestInit = {}): Promise { + const headers = buildRequestHeaders(init.headers); + return fetch(input, { + ...init, + ...(headers ? { headers } : {}), + }); +} + /** * Encodes query parameters while skipping undefined and null values. */ @@ -72,7 +105,7 @@ export async function listDocuments(options?: { processed_from: options?.processedFrom, processed_to: options?.processedTo, }); - const response = await fetch(`${API_BASE}/documents${query}`); + const response = await apiRequest(`${API_BASE}/documents${query}`); if (!response.ok) { throw new Error('Failed to load documents'); } @@ -108,7 +141,7 @@ export async function searchDocuments( processed_from: options?.processedFrom, processed_to: options?.processedTo, }); - const response = await fetch(`${API_BASE}/search${query}`); + const response = await apiRequest(`${API_BASE}/search${query}`); if (!response.ok) { throw new Error('Search failed'); } @@ -128,7 +161,7 @@ export async function listProcessingLogs(options?: { offset: options?.offset ?? 0, document_id: options?.documentId, }); - const response = await fetch(`${API_BASE}/processing/logs${query}`); + const response = await apiRequest(`${API_BASE}/processing/logs${query}`); if (!response.ok) { throw new Error('Failed to load processing logs'); } @@ -146,7 +179,7 @@ export async function trimProcessingLogs(options?: { keep_document_sessions: options?.keepDocumentSessions ?? 2, keep_unbound_entries: options?.keepUnboundEntries ?? 80, }); - const response = await fetch(`${API_BASE}/processing/logs/trim${query}`, { + const response = await apiRequest(`${API_BASE}/processing/logs/trim${query}`, { method: 'POST', }); if (!response.ok) { @@ -159,7 +192,7 @@ export async function trimProcessingLogs(options?: { * Clears all persisted processing logs. */ export async function clearProcessingLogs(): Promise<{ deleted_entries: number }> { - const response = await fetch(`${API_BASE}/processing/logs/clear`, { + const response = await apiRequest(`${API_BASE}/processing/logs/clear`, { method: 'POST', }); if (!response.ok) { @@ -173,7 +206,7 @@ export async function clearProcessingLogs(): Promise<{ deleted_entries: number } */ export async function listTags(includeTrashed = false): Promise { const query = buildQuery({ include_trashed: includeTrashed }); - const response = await fetch(`${API_BASE}/documents/tags${query}`); + const response = await apiRequest(`${API_BASE}/documents/tags${query}`); if (!response.ok) { throw new Error('Failed to load tags'); } @@ -186,7 +219,7 @@ export async function listTags(includeTrashed = false): Promise { */ export async function listPaths(includeTrashed = false): Promise { const query = buildQuery({ include_trashed: includeTrashed }); - const response = await fetch(`${API_BASE}/documents/paths${query}`); + const response = await apiRequest(`${API_BASE}/documents/paths${query}`); if (!response.ok) { throw new Error('Failed to load paths'); } @@ -199,7 +232,7 @@ export async function listPaths(includeTrashed = false): Promise { */ export async function listTypes(includeTrashed = false): Promise { const query = buildQuery({ include_trashed: includeTrashed }); - const response = await fetch(`${API_BASE}/documents/types${query}`); + const response = await apiRequest(`${API_BASE}/documents/types${query}`); if (!response.ok) { throw new Error('Failed to load document types'); } @@ -228,7 +261,7 @@ export async function uploadDocuments( formData.append('tags', options.tags); formData.append('conflict_mode', options.conflictMode); - const response = await fetch(`${API_BASE}/documents/upload`, { + const response = await apiRequest(`${API_BASE}/documents/upload`, { method: 'POST', body: formData, }); @@ -245,7 +278,7 @@ export async function updateDocumentMetadata( documentId: string, payload: { original_filename?: string; logical_path?: string; tags?: string[] }, ): Promise { - const response = await fetch(`${API_BASE}/documents/${documentId}`, { + const response = await apiRequest(`${API_BASE}/documents/${documentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -262,7 +295,7 @@ export async function updateDocumentMetadata( * Moves a document to trash state without removing stored files. */ export async function trashDocument(documentId: string): Promise { - const response = await fetch(`${API_BASE}/documents/${documentId}/trash`, { method: 'POST' }); + const response = await apiRequest(`${API_BASE}/documents/${documentId}/trash`, { method: 'POST' }); if (!response.ok) { throw new Error('Failed to trash document'); } @@ -273,7 +306,7 @@ export async function trashDocument(documentId: string): Promise { * Restores a document from trash to active state. */ export async function restoreDocument(documentId: string): Promise { - const response = await fetch(`${API_BASE}/documents/${documentId}/restore`, { method: 'POST' }); + const response = await apiRequest(`${API_BASE}/documents/${documentId}/restore`, { method: 'POST' }); if (!response.ok) { throw new Error('Failed to restore document'); } @@ -284,7 +317,7 @@ export async function restoreDocument(documentId: string): Promise * Permanently deletes a document record and associated stored files. */ export async function deleteDocument(documentId: string): Promise<{ deleted_documents: number; deleted_files: number }> { - const response = await fetch(`${API_BASE}/documents/${documentId}`, { method: 'DELETE' }); + const response = await apiRequest(`${API_BASE}/documents/${documentId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete document'); } @@ -295,7 +328,7 @@ export async function deleteDocument(documentId: string): Promise<{ deleted_docu * Loads full details for one document, including extracted text content. */ export async function getDocumentDetails(documentId: string): Promise { - const response = await fetch(`${API_BASE}/documents/${documentId}`); + const response = await apiRequest(`${API_BASE}/documents/${documentId}`); if (!response.ok) { throw new Error('Failed to load document details'); } @@ -306,7 +339,7 @@ export async function getDocumentDetails(documentId: string): Promise { - const response = await fetch(`${API_BASE}/documents/${documentId}/reprocess`, { + const response = await apiRequest(`${API_BASE}/documents/${documentId}/reprocess`, { method: 'POST', }); if (!response.ok) { @@ -352,7 +385,7 @@ export async function exportContentsMarkdown(payload: { include_trashed?: boolean; only_trashed?: boolean; }): Promise<{ blob: Blob; filename: string }> { - const response = await fetch(`${API_BASE}/documents/content-md/export`, { + const response = await apiRequest(`${API_BASE}/documents/content-md/export`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -373,7 +406,7 @@ export async function exportContentsMarkdown(payload: { * Retrieves persisted application settings from backend. */ export async function getAppSettings(): Promise { - const response = await fetch(`${API_BASE}/settings`); + const response = await apiRequest(`${API_BASE}/settings`); if (!response.ok) { throw new Error('Failed to load application settings'); } @@ -384,7 +417,7 @@ export async function getAppSettings(): Promise { * Updates provider and task settings for OpenAI-compatible model execution. */ export async function updateAppSettings(payload: AppSettingsUpdate): Promise { - const response = await fetch(`${API_BASE}/settings`, { + const response = await apiRequest(`${API_BASE}/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -401,7 +434,7 @@ export async function updateAppSettings(payload: AppSettingsUpdate): Promise { - const response = await fetch(`${API_BASE}/settings/reset`, { + const response = await apiRequest(`${API_BASE}/settings/reset`, { method: 'POST', }); if (!response.ok) { diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..e944723 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/actionmodal.tsx","./src/components/documentcard.tsx","./src/components/documentgrid.tsx","./src/components/documentviewer.tsx","./src/components/pathinput.tsx","./src/components/processinglogpanel.tsx","./src/components/searchfiltersbar.tsx","./src/components/settingsscreen.tsx","./src/components/taginput.tsx","./src/components/uploadsurface.tsx","./src/lib/api.ts"],"version":"5.9.2"} \ No newline at end of file