diff --git a/.env b/.env index f25c0b3..0ae72ba 100644 --- a/.env +++ b/.env @@ -1,11 +1,13 @@ -VITE_API_BASE_URL=http://127.0.0.1:8000 +# API origin only. Do not append /api or /api/v1; API helpers add /api/v1. +# Leave empty for same-origin requests (recommended for Vite development and the bundled WebUI). +VITE_API_BASE_URL= # Web UI WEBUI_PUBLISHED_PORT=5173 -# API base url without /api/v1 or similar parts -VITE_API_BASE_URL= +# For local Vite development outside Docker: # For local Vite development outside Docker: VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000 + CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080 MULTIMAILER_HOST=msm.localhost TRAEFIK_API_ROUTER_NAME=msm-api diff --git a/.env.example b/.env.example index d8d6202..e3d555e 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,12 @@ -VITE_API_BASE_URL=http://127.0.0.1:8000 +# API origin only. Do not append /api or /api/v1; API helpers add /api/v1. +# Leave empty for same-origin requests (recommended for Vite development and the bundled WebUI). +VITE_API_BASE_URL= # Web UI WEBUI_PUBLISHED_PORT=5173 -# API base url without /api/v1 or similar parts -VITE_API_BASE_URL= # For local Vite development outside Docker: -# VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000 +# VITE_API_PROXY_TARGET=http://127.0.0.1:8000 + CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080 MULTIMAILER_HOST=multimailer.localhost TRAEFIK_API_ROUTER_NAME=multimailer-api diff --git a/src/api/client.ts b/src/api/client.ts index 4a28e03..88937e7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,22 +2,42 @@ import type { ApiSettings } from "../types"; const STORAGE_KEY = "multimailer.apiSettings"; +/** + * API endpoint helpers already pass paths beginning with /api/v1. The configured + * value is therefore an origin only. Normalize legacy values that included the + * API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs. + */ +export function normalizeApiBaseUrl(value: string): string { + const withoutTrailingSlash = value.trim().replace(/\/+$/, ""); + return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, ""); +} + +export function apiUrl(settings: ApiSettings, path: string): string { + const baseUrl = normalizeApiBaseUrl(settings.apiBaseUrl); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath; +} + export function loadApiSettings(): ApiSettings { const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`); + const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? ""; + const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl); + + // Repair legacy values once loaded, while keeping an empty value for same-origin use. + if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) { + localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl); + } return { // Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI. - apiBaseUrl: - storedBaseUrl !== null - ? storedBaseUrl - : import.meta.env.VITE_API_BASE_URL ?? "", + apiBaseUrl, apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "", accessToken: localStorage.getItem(`${STORAGE_KEY}.accessToken`) || "" }; } export function saveApiSettings(settings: ApiSettings): void { - localStorage.setItem(`${STORAGE_KEY}.baseUrl`, settings.apiBaseUrl); + localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl)); localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey); localStorage.setItem(`${STORAGE_KEY}.accessToken`, settings.accessToken); } @@ -27,9 +47,6 @@ export function clearAccessToken(): void { } export async function apiFetch(settings: ApiSettings, path: string, init?: RequestInit): Promise { - const baseUrl = settings.apiBaseUrl.trim().replace(/\/$/, ""); - const url = baseUrl ? `${baseUrl}${path}` : path; - const headers = new Headers(init?.headers || {}); if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) { @@ -42,7 +59,7 @@ export async function apiFetch(settings: ApiSettings, path: string, init?: Re headers.set("X-API-Key", settings.apiKey); } - const response = await fetch(url, { ...init, headers }); + const response = await fetch(apiUrl(settings, path), { ...init, headers }); if (!response.ok) { const text = await response.text(); diff --git a/src/api/files.ts b/src/api/files.ts index 630e3cb..26cb4ff 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "./client"; +import { apiFetch, apiUrl } from "./client"; import type { ApiSettings } from "../types"; export type FileSpace = { @@ -71,11 +71,6 @@ function authHeaders(settings: ApiSettings): Headers { return headers; } -function apiUrl(settings: ApiSettings, path: string): string { - const baseUrl = settings.apiBaseUrl.trim().replace(/\/$/, ""); - return baseUrl ? `${baseUrl}${path}` : path; -} - export function listFileSpaces(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/files/spaces"); } diff --git a/src/components/DismissibleAlert.tsx b/src/components/DismissibleAlert.tsx index acecccf..679d9e5 100644 --- a/src/components/DismissibleAlert.tsx +++ b/src/components/DismissibleAlert.tsx @@ -9,6 +9,7 @@ type DismissibleAlertProps = { dismissible?: boolean; className?: string; compact?: boolean; + floating?: boolean; resetKey?: string | number; }; @@ -18,6 +19,7 @@ export default function DismissibleAlert({ dismissible = true, className = "", compact = false, + floating = false, resetKey }: DismissibleAlertProps) { const [visible, setVisible] = useState(true); @@ -28,8 +30,14 @@ export default function DismissibleAlert({ if (!visible) return null; + const role = tone === "danger" || tone === "warning" ? "alert" : "status"; + return ( -
+
{children}
{dismissible && (