From 04319025de27be605664dfdeb9e70f63f7b64464 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 08:18:39 +0000 Subject: [PATCH] fix(api): attach Bearer token to services/widgets/backups requests Under AUTH_ENABLED=true, api/services.ts, api/widgets.ts, and api/backups.ts called fetch() directly without attaching the OIDC access token, so every services/widgets/backups request 401'd while api/client.ts requests succeeded. The token was only attached in client.ts. Extract the auth-attaching fetch helpers (buildUrl/buildHeaders/ readErrorDetail + get/post/put/del/postForm) into a new api/shared.ts that consults getAccessToken(), rewrite services.ts/widgets.ts/ backups.ts to use them, and consolidate client.ts to import from shared.ts (removing its duplicated copies). Now every backend request goes through one auth-attaching path. As a side benefit, error messages surface the HTTP status + backend detail instead of a generic "Failed to ..." string. Bug masked in dev because dev runs AUTH_ENABLED=false. npm run build clean; 0 lint errors; 72 frontend tests pass. --- frontend/src/api/backups.ts | 62 ++++++++----------- frontend/src/api/client.ts | 100 +------------------------------ frontend/src/api/services.ts | 39 +++--------- frontend/src/api/shared.ts | 113 +++++++++++++++++++++++++++++++++++ frontend/src/api/widgets.ts | 37 +++--------- 5 files changed, 156 insertions(+), 195 deletions(-) create mode 100644 frontend/src/api/shared.ts diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts index 1a7e229..90a68a2 100644 --- a/frontend/src/api/backups.ts +++ b/frontend/src/api/backups.ts @@ -1,3 +1,4 @@ +import { get, post } from "./shared"; import type { BackupAlert, BackupDashboardSummary, @@ -5,59 +6,48 @@ import type { BackupRun, } from "../types/backups"; -const API_BASE = "/api"; - export async function fetchBackupJobs(): Promise { - const res = await fetch(`${API_BASE}/backups/jobs`); - if (!res.ok) throw new Error("Failed to fetch backup jobs"); - return res.json(); + return get("/api/backups/jobs"); } -export async function fetchBackupJob(jobId: string): Promise<{ job: BackupJob; runs: BackupRun[] }> { - const res = await fetch(`${API_BASE}/backups/jobs/${jobId}`); - if (!res.ok) throw new Error("Failed to fetch backup job"); - return res.json(); +export async function fetchBackupJob( + jobId: string, +): Promise<{ job: BackupJob; runs: BackupRun[] }> { + return get<{ job: BackupJob; runs: BackupRun[] }>(`/api/backups/jobs/${jobId}`); } -export async function fetchBackupRuns(jobId?: string, status?: string): Promise { - const params = new URLSearchParams(); - if (jobId) params.append("job_id", jobId); - if (status) params.append("status", status); - const res = await fetch(`${API_BASE}/backups/runs?${params}`); - if (!res.ok) throw new Error("Failed to fetch backup runs"); - return res.json(); +export async function fetchBackupRuns( + jobId?: string, + status?: string, +): Promise { + return get("/api/backups/runs", { + ...(jobId ? { job_id: jobId } : {}), + ...(status ? { status } : {}), + }); } export async function fetchBackupRun(runId: string): Promise { - const res = await fetch(`${API_BASE}/backups/runs/${runId}`); - if (!res.ok) throw new Error("Failed to fetch backup run"); - return res.json(); + return get(`/api/backups/runs/${runId}`); } export async function fetchBackupAlerts( jobId?: string, acknowledged?: boolean, - severity?: string + severity?: string, ): Promise { - const params = new URLSearchParams(); - if (jobId) params.append("job_id", jobId); - if (acknowledged !== undefined) params.append("acknowledged", String(acknowledged)); - if (severity) params.append("severity", severity); - const res = await fetch(`${API_BASE}/backups/alerts?${params}`); - if (!res.ok) throw new Error("Failed to fetch backup alerts"); - return res.json(); + return get("/api/backups/alerts", { + ...(jobId ? { job_id: jobId } : {}), + ...(acknowledged !== undefined ? { acknowledged: String(acknowledged) } : {}), + ...(severity ? { severity } : {}), + }); } -export async function acknowledgeBackupAlert(alertId: string): Promise { - const res = await fetch(`${API_BASE}/backups/alerts/${alertId}/acknowledge`, { - method: "POST", - }); - if (!res.ok) throw new Error("Failed to acknowledge alert"); - return res.json(); +export async function acknowledgeBackupAlert( + alertId: string, +): Promise { + return post(`/api/backups/alerts/${alertId}/acknowledge`); } export async function fetchBackupDashboard(): Promise { - const res = await fetch(`${API_BASE}/dashboard/backups`); - if (!res.ok) throw new Error("Failed to fetch backup dashboard"); - return res.json(); + return get("/api/dashboard/backups"); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7075fa1..24d9944 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,7 +2,6 @@ * Typed API client for the FastAPI backend. */ -import { getAccessToken } from "../auth"; import type { MediaCounts, LibraryCount, @@ -37,104 +36,7 @@ import type { PrometheusStatus, PrometheusTarget, } from "../types"; - -const BASE_URL = import.meta.env.VITE_API_URL || "/api"; - -function isAbsoluteUrl(value: string): boolean { - return /^https?:\/\//i.test(value) || value.startsWith("//"); -} - -function buildUrl(path: string, params?: Record): string { - if (!isAbsoluteUrl(BASE_URL)) { - const url = new URL(path, window.location.origin); - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== "") - url.searchParams.set(key, value); - }); - } - return url.toString(); - } - - const url = new URL(path, BASE_URL); - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== "") url.searchParams.set(key, value); - }); - } - return url.toString(); -} - -async function readErrorDetail(response: Response): Promise { - const text = await response.text(); - try { - const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown }; - const detail = parsed.detail ?? parsed.message; - if (typeof detail === "string" && detail.trim()) { - return detail; - } - } catch { - // Fall back to the raw response body below. - } - return text; -} - -function buildHeaders(isJsonBody: boolean): Headers { - const headers = new Headers(); - const token = getAccessToken(); - if (token) headers.set("Authorization", `Bearer ${token}`); - if (isJsonBody) headers.set("Content-Type", "application/json"); - return headers; -} - -async function get( - path: string, - params?: Record, -): Promise { - const response = await fetch(buildUrl(path, params), { - headers: buildHeaders(false), - }); - if (!response.ok) { - throw new Error(`${response.status}: ${await readErrorDetail(response)}`); - } - return response.json(); -} - -async function post(path: string, body?: unknown): Promise { - const response = await fetch(buildUrl(path), { - method: "POST", - headers: buildHeaders(true), - body: body ? JSON.stringify(body) : undefined, - }); - if (!response.ok) { - throw new Error(`${response.status}: ${await readErrorDetail(response)}`); - } - return response.json(); -} - -async function postForm(path: string, body: FormData): Promise { - const headers = buildHeaders(false); - const response = await fetch(buildUrl(path), { - method: "POST", - headers, - body, - }); - if (!response.ok) { - throw new Error(`${response.status}: ${await readErrorDetail(response)}`); - } - return response.json(); -} - -async function del(path: string): Promise { - const response = await fetch(buildUrl(path), { - method: "DELETE", - headers: buildHeaders(false), - }); - if (!response.ok) { - throw new Error(`${response.status}: ${await readErrorDetail(response)}`); - } - return response.json(); -} +import { buildHeaders, buildUrl, del, get, post, postForm, readErrorDetail } from "./shared"; // Dashboard (Jellyfin-backed; selected via jellyfin_service_id) export const fetchCounts = (jellyfinServiceId?: string) => diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index 7322e42..edcc0fa 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -1,59 +1,38 @@ +import { del, get, post, put } from "./shared"; import type { ServiceInstance, ServiceInstanceInput, ServiceTypeInfo, } from "../types"; -const API_BASE = "/api"; - export async function fetchServiceTypes(): Promise { - const res = await fetch(`${API_BASE}/services/types`); - if (!res.ok) throw new Error("Failed to fetch service types"); - return res.json(); + return get("/api/services/types"); } export async function fetchServiceInstances( serviceType?: string, ): Promise { - const query = serviceType - ? `?service_type=${encodeURIComponent(serviceType)}` - : ""; - const res = await fetch(`${API_BASE}/services/instances${query}`); - if (!res.ok) throw new Error("Failed to fetch service instances"); - return res.json(); + return get( + "/api/services/instances", + serviceType ? { service_type: serviceType } : undefined, + ); } export async function createServiceInstance( input: ServiceInstanceInput, ): Promise { - const res = await fetch(`${API_BASE}/services/instances`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error("Failed to create service instance"); - return res.json(); + return post("/api/services/instances", input); } export async function updateServiceInstance( input: ServiceInstanceInput, ): Promise { if (!input.id) throw new Error("Service ID is required for update"); - const res = await fetch(`${API_BASE}/services/instances/${input.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error("Failed to update service instance"); - return res.json(); + return put(`/api/services/instances/${input.id}`, input); } export async function deleteServiceInstance( serviceId: string, ): Promise<{ status: string }> { - const res = await fetch(`${API_BASE}/services/instances/${serviceId}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("Failed to delete service instance"); - return res.json(); + return del<{ status: string }>(`/api/services/instances/${serviceId}`); } diff --git a/frontend/src/api/shared.ts b/frontend/src/api/shared.ts new file mode 100644 index 0000000..6e9de9b --- /dev/null +++ b/frontend/src/api/shared.ts @@ -0,0 +1,113 @@ +/** + * Shared, auth-attaching API helpers for the FastAPI backend. + * + * Every backend request must go through these helpers so the OIDC access token + * (when auth is enabled) is attached consistently. Rolling a raw `fetch` without + * these will 401 under `AUTH_ENABLED=true`. + */ + +import { getAccessToken } from "../auth"; + +export const API_BASE = import.meta.env.VITE_API_URL || "/api"; + +function isAbsoluteUrl(value: string): boolean { + return /^https?:\/\//i.test(value) || value.startsWith("//"); +} + +export function buildUrl( + path: string, + params?: Record, +): string { + const base = isAbsoluteUrl(API_BASE) ? API_BASE : window.location.origin; + const url = new URL(path, base); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") { + url.searchParams.set(key, value); + } + } + } + return url.toString(); +} + +export async function readErrorDetail(response: Response): Promise { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown }; + const detail = parsed.detail ?? parsed.message; + if (typeof detail === "string" && detail.trim()) { + return detail; + } + } catch { + // Fall back to the raw response body below. + } + return text; +} + +export function buildHeaders(isJsonBody: boolean): Headers { + const headers = new Headers(); + const token = getAccessToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (isJsonBody) headers.set("Content-Type", "application/json"); + return headers; +} + +export async function get( + path: string, + params?: Record, +): Promise { + const response = await fetch(buildUrl(path, params), { + headers: buildHeaders(false), + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} + +export async function post(path: string, body?: unknown): Promise { + const response = await fetch(buildUrl(path), { + method: "POST", + headers: buildHeaders(true), + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} + +export async function postForm(path: string, body: FormData): Promise { + const response = await fetch(buildUrl(path), { + method: "POST", + headers: buildHeaders(false), + body, + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} + +export async function put(path: string, body?: unknown): Promise { + const response = await fetch(buildUrl(path), { + method: "PUT", + headers: buildHeaders(true), + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} + +export async function del(path: string): Promise { + const response = await fetch(buildUrl(path), { + method: "DELETE", + headers: buildHeaders(false), + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} diff --git a/frontend/src/api/widgets.ts b/frontend/src/api/widgets.ts index c395f84..ef3fc50 100644 --- a/frontend/src/api/widgets.ts +++ b/frontend/src/api/widgets.ts @@ -1,3 +1,4 @@ +import { del, get, post, put } from "./shared"; import type { BuiltinWidgetKindInfo, WidgetDataResponse, @@ -5,61 +6,37 @@ import type { WidgetInstanceInput, } from "../types"; -const API_BASE = "/api"; - export async function fetchBuiltinWidgetKinds(): Promise< BuiltinWidgetKindInfo[] > { - const res = await fetch(`${API_BASE}/widgets/builtin`); - if (!res.ok) throw new Error("Failed to fetch built-in widget kinds"); - return res.json(); + return get("/api/widgets/builtin"); } export async function fetchWidgetInstances(): Promise { - const res = await fetch(`${API_BASE}/widgets/instances`); - if (!res.ok) throw new Error("Failed to fetch widget instances"); - return res.json(); + return get("/api/widgets/instances"); } export async function createWidgetInstance( input: WidgetInstanceInput, ): Promise { - const res = await fetch(`${API_BASE}/widgets/instances`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error("Failed to create widget instance"); - return res.json(); + return post("/api/widgets/instances", input); } export async function updateWidgetInstance( input: WidgetInstanceInput, ): Promise { if (!input.id) throw new Error("Widget ID is required for update"); - const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error("Failed to update widget instance"); - return res.json(); + return put(`/api/widgets/instances/${input.id}`, input); } export async function deleteWidgetInstance( widgetId: string, ): Promise<{ status: string }> { - const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("Failed to delete widget instance"); - return res.json(); + return del<{ status: string }>(`/api/widgets/instances/${widgetId}`); } export async function fetchWidgetData( widgetId: string, ): Promise { - const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`); - if (!res.ok) throw new Error("Failed to fetch widget data"); - return res.json(); + return get(`/api/widgets/instances/${widgetId}/data`); }