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.
This commit is contained in:
+26
-36
@@ -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<BackupJob[]> {
|
||||
const res = await fetch(`${API_BASE}/backups/jobs`);
|
||||
if (!res.ok) throw new Error("Failed to fetch backup jobs");
|
||||
return res.json();
|
||||
return get<BackupJob[]>("/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<BackupRun[]> {
|
||||
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<BackupRun[]> {
|
||||
return get<BackupRun[]>("/api/backups/runs", {
|
||||
...(jobId ? { job_id: jobId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBackupRun(runId: string): Promise<BackupRun> {
|
||||
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<BackupRun>(`/api/backups/runs/${runId}`);
|
||||
}
|
||||
|
||||
export async function fetchBackupAlerts(
|
||||
jobId?: string,
|
||||
acknowledged?: boolean,
|
||||
severity?: string
|
||||
severity?: string,
|
||||
): Promise<BackupAlert[]> {
|
||||
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<BackupAlert[]>("/api/backups/alerts", {
|
||||
...(jobId ? { job_id: jobId } : {}),
|
||||
...(acknowledged !== undefined ? { acknowledged: String(acknowledged) } : {}),
|
||||
...(severity ? { severity } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function acknowledgeBackupAlert(alertId: string): Promise<BackupAlert> {
|
||||
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<BackupAlert> {
|
||||
return post<BackupAlert>(`/api/backups/alerts/${alertId}/acknowledge`);
|
||||
}
|
||||
|
||||
export async function fetchBackupDashboard(): Promise<BackupDashboardSummary> {
|
||||
const res = await fetch(`${API_BASE}/dashboard/backups`);
|
||||
if (!res.ok) throw new Error("Failed to fetch backup dashboard");
|
||||
return res.json();
|
||||
return get<BackupDashboardSummary>("/api/dashboard/backups");
|
||||
}
|
||||
|
||||
@@ -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, string>): 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<string> {
|
||||
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<T>(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
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<T>(path: string, body?: unknown): Promise<T> {
|
||||
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<T>(path: string, body: FormData): Promise<T> {
|
||||
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<T>(path: string): Promise<T> {
|
||||
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) =>
|
||||
|
||||
@@ -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<ServiceTypeInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/services/types`);
|
||||
if (!res.ok) throw new Error("Failed to fetch service types");
|
||||
return res.json();
|
||||
return get<ServiceTypeInfo[]>("/api/services/types");
|
||||
}
|
||||
|
||||
export async function fetchServiceInstances(
|
||||
serviceType?: string,
|
||||
): Promise<ServiceInstance[]> {
|
||||
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<ServiceInstance[]>(
|
||||
"/api/services/instances",
|
||||
serviceType ? { service_type: serviceType } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createServiceInstance(
|
||||
input: ServiceInstanceInput,
|
||||
): Promise<ServiceInstance> {
|
||||
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<ServiceInstance>("/api/services/instances", input);
|
||||
}
|
||||
|
||||
export async function updateServiceInstance(
|
||||
input: ServiceInstanceInput,
|
||||
): Promise<ServiceInstance> {
|
||||
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<ServiceInstance>(`/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}`);
|
||||
}
|
||||
|
||||
@@ -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, string>,
|
||||
): 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<string> {
|
||||
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<T>(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
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<T>(path: string, body?: unknown): Promise<T> {
|
||||
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<T>(path: string, body: FormData): Promise<T> {
|
||||
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<T>(path: string, body?: unknown): Promise<T> {
|
||||
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<T>(path: string): Promise<T> {
|
||||
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();
|
||||
}
|
||||
@@ -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<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
|
||||
}
|
||||
|
||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances`);
|
||||
if (!res.ok) throw new Error("Failed to fetch widget instances");
|
||||
return res.json();
|
||||
return get<WidgetInstance[]>("/api/widgets/instances");
|
||||
}
|
||||
|
||||
export async function createWidgetInstance(
|
||||
input: WidgetInstanceInput,
|
||||
): Promise<WidgetInstance> {
|
||||
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<WidgetInstance>("/api/widgets/instances", input);
|
||||
}
|
||||
|
||||
export async function updateWidgetInstance(
|
||||
input: WidgetInstanceInput,
|
||||
): Promise<WidgetInstance> {
|
||||
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<WidgetInstance>(`/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<WidgetDataResponse> {
|
||||
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<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user