Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
+98 -18
View File
@@ -2,14 +2,20 @@
* Typed API client for the FastAPI backend.
*/
import { getAccessToken } from "../auth";
import type {
MediaCounts,
LibraryCount,
UserDirectoryResponse,
UserMessageResponse,
UserMessageQueueStatus,
SmtpTestResponse,
NowPlayingSession,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
MediaIndexStatus,
MediaIndexActionResponse,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
@@ -17,36 +23,89 @@ import type {
ResolvedPath,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
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();
}
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(path, BASE_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") url.searchParams.set(key, value);
});
}
const response = await fetch(url.toString());
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) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
const url = new URL(path, BASE_URL);
const response = await fetch(url.toString(), {
const response = await fetch(buildUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: buildHeaders(true),
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
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();
}
@@ -55,15 +114,23 @@ async function post<T>(path: string, body?: unknown): Promise<T> {
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
export const fetchLibraries = () =>
get<LibraryCount[]>("/api/dashboard/libraries");
export const fetchNowPlaying = () =>
get<NowPlayingSession[]>("/api/dashboard/now-playing");
export const fetchActivity = () =>
get<NowPlayingSession[]>("/api/dashboard/activity");
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
// Backward-compatible alias used by older hooks/components.
export const fetchNowPlaying = fetchActivity;
// Monitoring
export const fetchMonitoringStatus = () =>
get<MonitoringStatus>("/api/monitoring/status");
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
export const fetchMonitoringMetrics = (
lastSeconds?: number | null,
maxLines = 70_000,
) =>
get<MonitoringMetrics>("/api/monitoring/metrics", {
last_seconds: String(lastSeconds),
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
max_lines: String(maxLines),
});
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
export const startCollector = () =>
@@ -77,7 +144,11 @@ export const restartCollector = () =>
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<{ indexed_items: number }>("/api/media/build");
post<MediaIndexActionResponse>("/api/media/build");
export const stopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/stop");
export const forceStopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/force-stop");
export const queryMedia = (params: {
libraries?: string;
types?: string;
@@ -114,3 +185,12 @@ export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
export const fetchUserMessageQueueStatus = () =>
get<UserMessageQueueStatus>("/api/users/message/status");
export const testUserSmtpConnection = () =>
post<SmtpTestResponse>("/api/users/message/test-smtp");
export const sendUserMessage = (formData: FormData) =>
postForm<UserMessageResponse>("/api/users/message", formData);