From 7fdc4f0af0be42171d18a4644aa2f8349c810119 Mon Sep 17 00:00:00 2001 From: shuki Date: Sun, 12 Apr 2026 03:40:52 +0300 Subject: [PATCH] feat: support configurable basePath via NEXT_PUBLIC_BASE_PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for hosting Bulwark under a sub-path (e.g. /webmail) by reading basePath from the NEXT_PUBLIC_BASE_PATH env var at build time. Next.js's basePath config does not automatically prefix client-side fetch() calls — only navigation. This PR adds a small apiPath() helper that prepends the basePath to API URLs, and updates all client-side fetch calls to /api/ routes to use it. Usage: NEXT_PUBLIC_BASE_PATH=/webmail npm run build When NEXT_PUBLIC_BASE_PATH is empty or unset, behavior is unchanged. Changes: - next.config.ts: conditionally set basePath from env var, expose it as NEXT_PUBLIC_BASE_PATH to the client - lib/api-path.ts: new helper with unit tests - hooks/, lib/, stores/, components/, app/: wrap 98 fetch('/api/...') calls in apiPath() (server-side route.ts files not modified — basePath does not apply to them) --- app/[locale]/login/page.tsx | 3 +- app/admin/auth/page.tsx | 7 ++-- app/admin/branding/page.tsx | 11 +++--- app/admin/change-password/page.tsx | 3 +- app/admin/layout.tsx | 7 ++-- app/admin/login/page.tsx | 3 +- app/admin/logs/page.tsx | 3 +- app/admin/marketplace/page.tsx | 5 ++- app/admin/page.tsx | 17 ++++---- app/admin/plugins/[id]/page.tsx | 9 +++-- app/admin/plugins/page.tsx | 19 ++++----- app/admin/policy/page.tsx | 5 ++- app/admin/settings/page.tsx | 7 ++-- app/admin/themes/page.tsx | 19 ++++----- components/calendar/ical-import-modal.tsx | 3 +- components/layout/navigation-rail.tsx | 5 ++- .../settings/calendar-management-settings.tsx | 3 +- hooks/use-config.ts | 3 +- lib/api-path.test.ts | 35 +++++++++++++++++ lib/api-path.ts | 19 +++++++++ lib/plugin-api.ts | 9 +++-- next.config.ts | 6 +++ stores/account-security-store.ts | 23 +++++------ stores/auth-store.ts | 39 ++++++++++--------- stores/calendar-store.ts | 3 +- stores/plugin-store.ts | 5 ++- stores/policy-store.ts | 3 +- stores/settings-store.ts | 5 ++- stores/theme-store.ts | 7 ++-- 29 files changed, 186 insertions(+), 100 deletions(-) create mode 100644 lib/api-path.test.ts create mode 100644 lib/api-path.ts diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 9c29a7f5..13b77cc3 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -15,6 +15,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; +import { apiPath } from "@/lib/api-path"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -232,7 +233,7 @@ export default function LoginPage() { setOauthLoading(true); try { const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; - const res = await fetch('/api/auth/sso/start', { + const res = await fetch(apiPath('/api/auth/sso/start'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 05fed172..fee7ba9b 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Save, Loader2, RotateCcw } from 'lucide-react'; +import { apiPath } from "@/lib/api-path"; interface ConfigEntry { value: unknown; @@ -19,7 +20,7 @@ export default function AdminAuthPage() { async function fetchConfig() { setLoading(true); - const res = await fetch('/api/admin/config'); + const res = await fetch(apiPath('/api/admin/config')); if (res.ok) setConfig(await res.json()); setLoading(false); } @@ -39,7 +40,7 @@ export default function AdminAuthPage() { setSaving(true); setMessage(null); - const res = await fetch('/api/admin/config', { + const res = await fetch(apiPath('/api/admin/config'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits), @@ -57,7 +58,7 @@ export default function AdminAuthPage() { } async function handleRevert(key: string) { - const res = await fetch('/api/admin/config', { + const res = await fetch(apiPath('/api/admin/config'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), diff --git a/app/admin/branding/page.tsx b/app/admin/branding/page.tsx index 33bb12aa..d90321cb 100644 --- a/app/admin/branding/page.tsx +++ b/app/admin/branding/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; +import { apiPath } from "@/lib/api-path"; interface ConfigEntry { value: unknown; @@ -38,7 +39,7 @@ export default function AdminBrandingPage() { async function fetchConfig() { setLoading(true); - const res = await fetch('/api/admin/config'); + const res = await fetch(apiPath('/api/admin/config')); if (res.ok) setConfig(await res.json()); setLoading(false); } @@ -58,7 +59,7 @@ export default function AdminBrandingPage() { setSaving(true); setMessage(null); - const res = await fetch('/api/admin/config', { + const res = await fetch(apiPath('/api/admin/config'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits), @@ -83,7 +84,7 @@ export default function AdminBrandingPage() { formData.append('file', file); formData.append('slot', slot); - const res = await fetch('/api/admin/branding', { + const res = await fetch(apiPath('/api/admin/branding'), { method: 'POST', body: formData, }); @@ -112,7 +113,7 @@ export default function AdminBrandingPage() { async function handleDeleteUpload(slot: string) { setMessage(null); - const res = await fetch('/api/admin/branding', { + const res = await fetch(apiPath('/api/admin/branding'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slot }), @@ -133,7 +134,7 @@ export default function AdminBrandingPage() { } async function handleRevert(key: string) { - const res = await fetch('/api/admin/config', { + const res = await fetch(apiPath('/api/admin/config'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), diff --git a/app/admin/change-password/page.tsx b/app/admin/change-password/page.tsx index 6e220d3a..1909f183 100644 --- a/app/admin/change-password/page.tsx +++ b/app/admin/change-password/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Lock } from 'lucide-react'; +import { apiPath } from "@/lib/api-path"; export default function ChangePasswordPage() { const router = useRouter(); @@ -28,7 +29,7 @@ export default function ChangePasswordPage() { } setLoading(true); - const res = await fetch('/api/admin/change-password', { + const res = await fetch(apiPath('/api/admin/change-password'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }), diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 9e24470d..f224bcb8 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -27,6 +27,7 @@ import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { useAuthStore } from '@/stores/auth-store'; +import { apiPath } from "@/lib/api-path"; const NAV_GROUPS = [ { @@ -85,7 +86,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) async function checkAuth() { try { const jmapHeaders = getJmapHeaders(); - const res = await fetch('/api/admin/auth', { headers: jmapHeaders }); + const res = await fetch(apiPath('/api/admin/auth'), { headers: jmapHeaders }); const data = await res.json(); const stalwartAdmin = data.stalwartAdmin === true; @@ -104,7 +105,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) // If Stalwart admin but not yet authenticated, auto-login if (stalwartAdmin) { - const loginRes = await fetch('/api/admin/auth', { + const loginRes = await fetch(apiPath('/api/admin/auth'), { method: 'POST', headers: { 'Content-Type': 'application/json', ...jmapHeaders }, body: JSON.stringify({ stalwartAuth: true }), @@ -122,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) } async function handleLogout() { - await fetch('/api/admin/auth', { method: 'DELETE' }); + await fetch(apiPath('/api/admin/auth'), { method: 'DELETE' }); router.replace('/admin/login'); } diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index ec426b3f..cfacef71 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { Shield } from 'lucide-react'; import { useConfig } from '@/hooks/use-config'; import { useThemeStore } from '@/stores/theme-store'; +import { apiPath } from "@/lib/api-path"; export default function AdminLoginPage() { const router = useRouter(); @@ -21,7 +22,7 @@ export default function AdminLoginPage() { setLoading(true); try { - const res = await fetch('/api/admin/auth', { + const res = await fetch(apiPath('/api/admin/auth'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), diff --git a/app/admin/logs/page.tsx b/app/admin/logs/page.tsx index 9c79a3da..c24146d0 100644 --- a/app/admin/logs/page.tsx +++ b/app/admin/logs/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from 'react'; import { RefreshCw } from 'lucide-react'; import type { AuditEntry } from '@/lib/admin/types'; +import { apiPath } from "@/lib/api-path"; export default function AdminLogsPage() { const [entries, setEntries] = useState([]); @@ -17,7 +18,7 @@ export default function AdminLogsPage() { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (actionFilter) params.set('action', actionFilter); - const res = await fetch(`/api/admin/audit?${params}`); + const res = await fetch(apiPath(`/api/admin/audit?${params}`)); if (res.ok) { const data = await res.json(); setEntries(data.entries || []); diff --git a/app/admin/marketplace/page.tsx b/app/admin/marketplace/page.tsx index 08526eba..68005059 100644 --- a/app/admin/marketplace/page.tsx +++ b/app/admin/marketplace/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react'; +import { apiPath } from "@/lib/api-path"; interface Extension { slug: string; @@ -57,7 +58,7 @@ export default function AdminMarketplacePage() { params.set('perPage', String(perPage)); params.set('sort', 'newest'); - const res = await fetch(`/api/admin/marketplace?${params}`); + const res = await fetch(apiPath(`/api/admin/marketplace?${params}`)); if (!res.ok) { const data = await res.json().catch(() => ({})); setError(data.error || 'Failed to connect to extension directory'); @@ -95,7 +96,7 @@ export default function AdminMarketplacePage() { setMessage(null); try { - const res = await fetch('/api/admin/marketplace', { + const res = await fetch(apiPath('/api/admin/marketplace'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5b6c0538..0083d4af 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; import type { AuditEntry } from '@/lib/admin/types'; +import { apiPath } from "@/lib/api-path"; interface AdminStatus { enabled: boolean; @@ -38,13 +39,13 @@ export default function AdminDashboardPage() { async function fetchDashboardData() { const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([ - fetch('/api/admin/auth'), - fetch('/api/admin/audit?limit=10'), - fetch('/api/config'), - fetch('/api/admin/config'), - fetch('/api/admin/plugins').catch(() => null), - fetch('/api/admin/themes').catch(() => null), - fetch('/api/admin/policy').catch(() => null), + fetch(apiPath('/api/admin/auth')), + fetch(apiPath('/api/admin/audit?limit=10')), + fetch(apiPath('/api/config')), + fetch(apiPath('/api/admin/config')), + fetch(apiPath('/api/admin/plugins')).catch(() => null), + fetch(apiPath('/api/admin/themes')).catch(() => null), + fetch(apiPath('/api/admin/policy')).catch(() => null), ]); if (statusRes.ok) setStatus(await statusRes.json()); @@ -75,7 +76,7 @@ export default function AdminDashboardPage() { if (configData?.jmapServerUrl) { try { - const jmapRes = await fetch('/api/config'); + const jmapRes = await fetch(apiPath('/api/config')); setJmapHealth(jmapRes.ok ? 'ok' : 'error'); } catch { setJmapHealth('error'); diff --git a/app/admin/plugins/[id]/page.tsx b/app/admin/plugins/[id]/page.tsx index 95ca6be1..f6e15c2e 100644 --- a/app/admin/plugins/[id]/page.tsx +++ b/app/admin/plugins/[id]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; import Link from 'next/link'; +import { apiPath } from "@/lib/api-path"; interface ConfigField { type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; @@ -68,8 +69,8 @@ export default function PluginConfigPage() { setLoading(true); try { const [pluginsRes, configRes] = await Promise.all([ - fetch('/api/admin/plugins'), - fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), + fetch(apiPath('/api/admin/plugins')), + fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`)), ]); if (pluginsRes.ok) { @@ -117,7 +118,7 @@ export default function PluginConfigPage() { // Delete if clearing a non-required field if (!newVal && !field.required) { - const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { + const res = await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), @@ -130,7 +131,7 @@ export default function PluginConfigPage() { continue; } - const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { + const res = await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }), diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx index 9d95a323..20f7bf8f 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { apiPath } from "@/lib/api-path"; interface PluginEntry { id: string; @@ -34,7 +35,7 @@ export default function AdminPluginsPage() { async function fetchPolicy() { try { - const res = await fetch('/api/admin/policy'); + const res = await fetch(apiPath('/api/admin/policy')); if (res.ok) { const data = await res.json(); setPolicy(data); @@ -73,7 +74,7 @@ export default function AdminPluginsPage() { setSavingPolicy(true); setMessage(null); try { - const res = await fetch('/api/admin/policy', { + const res = await fetch(apiPath('/api/admin/policy'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(policy), @@ -95,7 +96,7 @@ export default function AdminPluginsPage() { async function fetchPlugins() { setLoading(true); try { - const res = await fetch('/api/admin/plugins'); + const res = await fetch(apiPath('/api/admin/plugins')); if (res.ok) setPlugins(await res.json()); } finally { setLoading(false); @@ -113,7 +114,7 @@ export default function AdminPluginsPage() { formData.append('file', file); try { - const res = await fetch('/api/admin/plugins', { + const res = await fetch(apiPath('/api/admin/plugins'), { method: 'POST', body: formData, }); @@ -136,7 +137,7 @@ export default function AdminPluginsPage() { async function togglePlugin(id: string, enabled: boolean) { setMessage(null); - const res = await fetch('/api/admin/plugins', { + const res = await fetch(apiPath('/api/admin/plugins'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, enabled }), @@ -156,7 +157,7 @@ export default function AdminPluginsPage() { const body: Record = { id, forceEnabled }; if (forceEnabled) body.enabled = true; - const res = await fetch('/api/admin/plugins', { + const res = await fetch(apiPath('/api/admin/plugins'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -190,7 +191,7 @@ export default function AdminPluginsPage() { } let failed = 0; for (const p of disabled) { - const res = await fetch('/api/admin/plugins', { + const res = await fetch(apiPath('/api/admin/plugins'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: p.id, enabled: true }), @@ -216,7 +217,7 @@ export default function AdminPluginsPage() { } let failed = 0; for (const p of enabled) { - const res = await fetch('/api/admin/plugins', { + const res = await fetch(apiPath('/api/admin/plugins'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: p.id, enabled: false }), @@ -236,7 +237,7 @@ export default function AdminPluginsPage() { if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return; setMessage(null); - const res = await fetch('/api/admin/plugins', { + const res = await fetch(apiPath('/api/admin/plugins'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index c2d82dec..c13e96f1 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { Save, Loader2, Lock } from 'lucide-react'; import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; +import { apiPath } from "@/lib/api-path"; // Feature gates managed on their own admin pages (excluded from this list) const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; @@ -54,7 +55,7 @@ export default function AdminPolicyPage() { async function fetchPolicy() { setLoading(true); try { - const res = await fetch('/api/admin/policy'); + const res = await fetch(apiPath('/api/admin/policy')); if (res.ok) { const data = await res.json(); setPolicy(data); @@ -106,7 +107,7 @@ export default function AdminPolicyPage() { setSaving(true); setMessage(null); - const res = await fetch('/api/admin/policy', { + const res = await fetch(apiPath('/api/admin/policy'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(policy), diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 24b12b35..7ec22593 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Save, RotateCcw, Loader2 } from 'lucide-react'; +import { apiPath } from "@/lib/api-path"; interface ConfigEntry { value: unknown; @@ -21,7 +22,7 @@ export default function AdminSettingsPage() { async function fetchConfig() { setLoading(true); - const res = await fetch('/api/admin/config'); + const res = await fetch(apiPath('/api/admin/config')); if (res.ok) { setConfig(await res.json()); } @@ -43,7 +44,7 @@ export default function AdminSettingsPage() { setSaving(true); setMessage(null); - const res = await fetch('/api/admin/config', { + const res = await fetch(apiPath('/api/admin/config'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits), @@ -61,7 +62,7 @@ export default function AdminSettingsPage() { } async function handleRevert(key: string) { - const res = await fetch('/api/admin/config', { + const res = await fetch(apiPath('/api/admin/config'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), diff --git a/app/admin/themes/page.tsx b/app/admin/themes/page.tsx index 4419c235..94d88181 100644 --- a/app/admin/themes/page.tsx +++ b/app/admin/themes/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'; import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; +import { apiPath } from "@/lib/api-path"; const BUILTIN_THEME_OPTIONS = [ { id: 'builtin-nord', name: 'Nord' }, @@ -38,7 +39,7 @@ export default function AdminThemesPage() { async function fetchPolicy() { try { - const res = await fetch('/api/admin/policy'); + const res = await fetch(apiPath('/api/admin/policy')); if (res.ok) { const data = await res.json(); setPolicy({ @@ -122,7 +123,7 @@ export default function AdminThemesPage() { setSavingPolicy(true); setMessage(null); try { - const res = await fetch('/api/admin/policy', { + const res = await fetch(apiPath('/api/admin/policy'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(policy), @@ -144,7 +145,7 @@ export default function AdminThemesPage() { async function fetchThemes() { setLoading(true); try { - const res = await fetch('/api/admin/themes'); + const res = await fetch(apiPath('/api/admin/themes')); if (res.ok) setThemes(await res.json()); } finally { setLoading(false); @@ -162,7 +163,7 @@ export default function AdminThemesPage() { formData.append('file', file); try { - const res = await fetch('/api/admin/themes', { + const res = await fetch(apiPath('/api/admin/themes'), { method: 'POST', body: formData, }); @@ -185,7 +186,7 @@ export default function AdminThemesPage() { async function toggleTheme(id: string, enabled: boolean) { setMessage(null); - const res = await fetch('/api/admin/themes', { + const res = await fetch(apiPath('/api/admin/themes'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, enabled }), @@ -204,7 +205,7 @@ export default function AdminThemesPage() { const body: Record = { id, forceEnabled }; if (forceEnabled) body.enabled = true; - const res = await fetch('/api/admin/themes', { + const res = await fetch(apiPath('/api/admin/themes'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -237,7 +238,7 @@ export default function AdminThemesPage() { } let failed = 0; for (const t of disabled) { - const res = await fetch('/api/admin/themes', { + const res = await fetch(apiPath('/api/admin/themes'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, enabled: true }), @@ -262,7 +263,7 @@ export default function AdminThemesPage() { } let failed = 0; for (const t of enabled) { - const res = await fetch('/api/admin/themes', { + const res = await fetch(apiPath('/api/admin/themes'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, enabled: false }), @@ -282,7 +283,7 @@ export default function AdminThemesPage() { if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return; setMessage(null); - const res = await fetch('/api/admin/themes', { + const res = await fetch(apiPath('/api/admin/themes'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 834ad009..af60c029 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -11,6 +11,7 @@ import { getEventStartDate } from "@/lib/calendar-utils"; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; +import { apiPath } from "@/lib/api-path"; interface ICalImportModalProps { calendars: Calendar[]; @@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP setIsParsing(true); try { - const response = await fetch("/api/fetch-ical", { + const response = await fetch(apiPath("/api/fetch-ical"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: trimmed }), diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 37cab596..626c0b79 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -22,6 +22,7 @@ import { getInitials } from "@/lib/account-utils"; import { cn, formatFileSize } from "@/lib/utils"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; +import { apiPath } from "@/lib/api-path"; interface NavItem { id: string; @@ -222,13 +223,13 @@ export function NavigationRail({ let cancelled = false; const headers = getActiveAccountSlotHeaders(); if (!headers['X-JMAP-Cookie-Slot']) return; - fetch('/api/admin/stalwart-check', { headers }) + fetch(apiPath('/api/admin/stalwart-check'), { headers }) .then(res => res.json()) .then(data => { if (!cancelled && data.isStalwartAdmin) { setIsStalwartAdmin(true); // Pre-create admin session so /admin works even after full page navigation - fetch('/api/admin/auth', { + fetch(apiPath('/api/admin/auth'), { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify({ stalwartAuth: true }), diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 1e1f22a2..e13d9d0c 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -12,6 +12,7 @@ import { cn, formatDateTime } from '@/lib/utils'; import { ICalImportModal } from '@/components/calendar/ical-import-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; import { useSettingsStore } from '@/stores/settings-store'; +import { apiPath } from "@/lib/api-path"; const CALENDAR_COLORS = [ "#3b82f6", // blue @@ -202,7 +203,7 @@ export function CalendarManagementSettings() { const controller = new AbortController(); - fetch('/api/caldav/discover', { + fetch(apiPath('/api/caldav/discover'), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/hooks/use-config.ts b/hooks/use-config.ts index c884ea05..8024b389 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { usePolicyStore } from '@/stores/policy-store'; +import { apiPath } from "@/lib/api-path"; interface ConfigData { appName: string; @@ -50,7 +51,7 @@ export async function fetchConfig(): Promise { } // Start a new fetch - configPromise = fetch('/api/config') + configPromise = fetch(apiPath('/api/config')) .then((res) => { if (!res.ok) { throw new Error('Failed to fetch config'); diff --git a/lib/api-path.test.ts b/lib/api-path.test.ts new file mode 100644 index 00000000..9215fed3 --- /dev/null +++ b/lib/api-path.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { apiPath } from "./api-path"; + +describe("apiPath", () => { + const original = process.env.NEXT_PUBLIC_BASE_PATH; + + afterEach(() => { + process.env.NEXT_PUBLIC_BASE_PATH = original; + }); + + it("returns the path unchanged when NEXT_PUBLIC_BASE_PATH is empty", () => { + process.env.NEXT_PUBLIC_BASE_PATH = ""; + expect(apiPath("/api/foo")).toBe("/api/foo"); + }); + + it("returns the path unchanged when NEXT_PUBLIC_BASE_PATH is undefined", () => { + delete process.env.NEXT_PUBLIC_BASE_PATH; + expect(apiPath("/api/foo")).toBe("/api/foo"); + }); + + it("prepends basePath when configured", () => { + process.env.NEXT_PUBLIC_BASE_PATH = "/webmail"; + expect(apiPath("/api/foo")).toBe("/webmail/api/foo"); + }); + + it("does not double-prefix paths that already start with basePath", () => { + process.env.NEXT_PUBLIC_BASE_PATH = "/webmail"; + expect(apiPath("/webmail/api/foo")).toBe("/webmail/api/foo"); + }); + + it("works with nested basePath values", () => { + process.env.NEXT_PUBLIC_BASE_PATH = "/app/mail"; + expect(apiPath("/api/auth")).toBe("/app/mail/api/auth"); + }); +}); diff --git a/lib/api-path.ts b/lib/api-path.ts new file mode 100644 index 00000000..061098fc --- /dev/null +++ b/lib/api-path.ts @@ -0,0 +1,19 @@ +/** + * Prefix API paths with the configured basePath. + * + * Next.js's `basePath` config does not automatically prefix `fetch()` calls. + * This helper ensures client-side API requests target the correct URL when + * Bulwark is hosted under a sub-path (e.g. `/webmail`). + * + * Set `NEXT_PUBLIC_BASE_PATH` (e.g. `"/webmail"`) to serve under a sub-path. + * When unset, paths are returned unchanged. + * + * @example + * fetch(apiPath("/api/auth/session")) // → /webmail/api/auth/session + */ +export function apiPath(path: string): string { + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + if (!basePath) return path; + if (path.startsWith(basePath)) return path; + return `${basePath}${path}`; +} diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 2a2d4353..10a199f7 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -26,6 +26,7 @@ import { } from './plugin-hooks'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; +import { apiPath } from "@/lib/api-path"; // --- Permission helpers -------------------------------------- @@ -676,20 +677,20 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { admin: { getConfig: async (key: string) => { requirePermission(plugin, 'admin:config'); - const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`); + const res = await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`)); if (!res.ok) return null; const data = await res.json(); return data[key] ?? null; }, getAllConfig: async () => { requirePermission(plugin, 'admin:config'); - const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`); + const res = await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`)); if (!res.ok) return {}; return res.json(); }, setConfig: async (key: string, value: unknown) => { requirePermission(plugin, 'admin:config'); - await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { + await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }), @@ -697,7 +698,7 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { }, deleteConfig: async (key: string) => { requirePermission(plugin, 'admin:config'); - await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { + await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), diff --git a/next.config.ts b/next.config.ts index cc78bd8e..843d8aa6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,15 +18,21 @@ try { // VERSION file not found } +// Support serving under a sub-path (e.g. "/webmail") via NEXT_PUBLIC_BASE_PATH. +// Must be set at build time — Next.js bakes it into the output. +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""; + const nextConfig: NextConfig = { output: "standalone", allowedDevOrigins: ["192.168.1.51"], turbopack: { root: import.meta.dirname, }, + ...(basePath ? { basePath } : {}), env: { NEXT_PUBLIC_GIT_COMMIT: gitCommitHash, NEXT_PUBLIC_APP_VERSION: appVersion, + NEXT_PUBLIC_BASE_PATH: basePath, }, }; diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index 405d55d9..4debcb6b 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { debug } from '@/lib/debug'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; +import { apiPath } from "@/lib/api-path"; interface AccountSecurityState { // Detection @@ -66,7 +67,7 @@ export const useAccountSecurityStore = create()((set, get) probe: async () => { set({ isProbing: true }); try { - const response = await fetch('/api/account/stalwart/probe', { + const response = await fetch(apiPath('/api/account/stalwart/probe'), { headers: getApiHeaders(), }); const data = await response.json(); @@ -83,7 +84,7 @@ export const useAccountSecurityStore = create()((set, get) fetchAuthInfo: async () => { set({ isLoadingAuth: true, error: null }); try { - const response = await fetch('/api/account/stalwart/auth', { + const response = await fetch(apiPath('/api/account/stalwart/auth'), { headers: getApiHeaders(), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -105,7 +106,7 @@ export const useAccountSecurityStore = create()((set, get) fetchCryptoInfo: async () => { set({ isLoadingCrypto: true, error: null }); try { - const response = await fetch('/api/account/stalwart/crypto', { + const response = await fetch(apiPath('/api/account/stalwart/crypto'), { headers: getApiHeaders(), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -126,7 +127,7 @@ export const useAccountSecurityStore = create()((set, get) fetchPrincipal: async () => { set({ isLoadingPrincipal: true, error: null }); try { - const response = await fetch('/api/account/stalwart/principal', { + const response = await fetch(apiPath('/api/account/stalwart/principal'), { headers: getApiHeaders(), }); if (!response.ok) { @@ -169,7 +170,7 @@ export const useAccountSecurityStore = create()((set, get) changePassword: async (currentPassword, newPassword) => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/password', { + const response = await fetch(apiPath('/api/account/stalwart/password'), { method: 'POST', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }), @@ -193,7 +194,7 @@ export const useAccountSecurityStore = create()((set, get) updateDisplayName: async (displayName) => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/principal', { + const response = await fetch(apiPath('/api/account/stalwart/principal'), { method: 'PATCH', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify([ @@ -219,7 +220,7 @@ export const useAccountSecurityStore = create()((set, get) enableTotp: async () => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/auth', { + const response = await fetch(apiPath('/api/account/stalwart/auth'), { method: 'POST', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify([{ type: 'enableOtpAuth' }]), @@ -245,7 +246,7 @@ export const useAccountSecurityStore = create()((set, get) disableTotp: async () => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/auth', { + const response = await fetch(apiPath('/api/account/stalwart/auth'), { method: 'POST', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify([{ type: 'disableOtpAuth' }]), @@ -269,7 +270,7 @@ export const useAccountSecurityStore = create()((set, get) addAppPassword: async (name, password) => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/auth', { + const response = await fetch(apiPath('/api/account/stalwart/auth'), { method: 'POST', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify([{ type: 'addAppPassword', name, password }]), @@ -295,7 +296,7 @@ export const useAccountSecurityStore = create()((set, get) removeAppPassword: async (name) => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/auth', { + const response = await fetch(apiPath('/api/account/stalwart/auth'), { method: 'POST', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify([{ type: 'removeAppPassword', name }]), @@ -321,7 +322,7 @@ export const useAccountSecurityStore = create()((set, get) updateEncryption: async (settings) => { set({ isSaving: true, error: null }); try { - const response = await fetch('/api/account/stalwart/crypto', { + const response = await fetch(apiPath('/api/account/stalwart/crypto'), { method: 'POST', headers: { ...getApiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify(settings), diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 3d141452..e73a72cb 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -16,6 +16,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath } from '@/lib/b import { notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; +import { apiPath } from "@/lib/api-path"; interface AuthState { isAuthenticated: boolean; @@ -95,7 +96,7 @@ async function syncStalwartAuthContext( slot: number, ): Promise { try { - const response = await fetch('/api/auth/stalwart-context', { + const response = await fetch(apiPath('/api/auth/stalwart-context'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl, username, authHeader, slot }), @@ -403,7 +404,7 @@ export const useAuthStore = create()( if (totp) { try { - const tokenRes = await fetch('/api/auth/totp-token-exchange', { + const tokenRes = await fetch(apiPath('/api/auth/totp-token-exchange'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), @@ -470,7 +471,7 @@ export const useAuthStore = create()( if (rememberMe && !upgradedToOAuth) { // For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials try { - const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, { + const res = await fetch(apiPath(`/api/auth/session?slot=${cookieSlot}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), @@ -607,7 +608,7 @@ export const useAuthStore = create()( : 0; const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot(); - const tokenRes = await fetch(`/api/auth/token?slot=${slot}`, { + const tokenRes = await fetch(apiPath(`/api/auth/token?slot=${slot}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }), @@ -718,7 +719,7 @@ export const useAuthStore = create()( try { // Server-side SSO: the server holds the PKCE verifier in an encrypted cookie - const ssoRes = await fetch('/api/auth/sso/complete', { + const ssoRes = await fetch(apiPath('/api/auth/sso/complete'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -841,7 +842,7 @@ export const useAuthStore = create()( const promise = (async () => { try { - const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' }); + const res = await fetch(apiPath(`/api/auth/token?slot=${slot}`), { method: 'PUT' }); if (!res.ok) { notifyParent('sso:session-expired'); @@ -963,9 +964,9 @@ export const useAuthStore = create()( } // Background cookie cleanup for the removed account - fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch(apiPath(`/api/auth/session?slot=${slot}`), { method: 'DELETE', keepalive: true }).catch(() => {}); if (wasOAuth) { - fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch(apiPath(`/api/auth/token?slot=${slot}`), { method: 'DELETE', keepalive: true }).catch(() => {}); } return; } @@ -977,9 +978,9 @@ export const useAuthStore = create()( // Background cookie/token cleanup — keepalive ensures completion during navigation if (!wasDemoMode) { - fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch(apiPath(`/api/auth/session?slot=${slot}`), { method: 'DELETE', keepalive: true }).catch(() => {}); if (wasOAuth) { - fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch(apiPath(`/api/auth/token?slot=${slot}`), { method: 'DELETE', keepalive: true }).catch(() => {}); } } @@ -1006,8 +1007,8 @@ export const useAuthStore = create()( } // Background cookie/token cleanup - fetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); - fetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch(apiPath('/api/auth/session?all=true'), { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch(apiPath('/api/auth/token?all=true'), { method: 'DELETE', keepalive: true }).catch(() => {}); redirectToLogin(); }, @@ -1041,7 +1042,7 @@ export const useAuthStore = create()( // Client not connected — try to restore try { if (targetAccount.authMode === 'oauth') { - const res = await fetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' }); + const res = await fetch(apiPath(`/api/auth/token?slot=${targetAccount.cookieSlot}`), { method: 'PUT' }); if (res.ok) { const { access_token, expires_in } = await res.json(); const refreshFn = get().refreshAccessToken; @@ -1058,7 +1059,7 @@ export const useAuthStore = create()( ); } } else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) { - const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' }); + const res = await fetch(apiPath(`/api/auth/session?slot=${targetAccount.cookieSlot}`), { method: 'PUT' }); if (res.ok) { const { serverUrl, username, password } = await res.json(); targetClient = new JMAPClient(serverUrl, username, password); @@ -1107,7 +1108,7 @@ export const useAuthStore = create()( // Cannot restore — remove the stale account and redirect to login evictAccount(accountId); accountStore.removeAccount(accountId); - fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); + fetch(apiPath(`/api/auth/session?slot=${targetAccount.cookieSlot}`), { method: 'DELETE' }).catch(() => {}); // Restore the previous account if still available if (state.activeAccountId && state.activeAccountId !== accountId) { @@ -1210,7 +1211,7 @@ export const useAuthStore = create()( try { if (account.authMode === 'oauth') { - const res = await fetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' }); + const res = await fetch(apiPath(`/api/auth/token?slot=${account.cookieSlot}`), { method: 'PUT' }); if (res.ok) { const { access_token, expires_in } = await res.json(); const refreshFn = get().refreshAccessToken; @@ -1225,7 +1226,7 @@ export const useAuthStore = create()( throw new Error(`Token refresh failed: ${res.status}`); } } else if (account.authMode === 'basic' && account.rememberMe) { - const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' }); + const res = await fetch(apiPath(`/api/auth/session?slot=${account.cookieSlot}`), { method: 'PUT' }); if (res.ok) { const { serverUrl, username, password } = await res.json(); const client = new JMAPClient(serverUrl, username, password); @@ -1255,7 +1256,7 @@ export const useAuthStore = create()( // again rather than seeing a stale error entry forever. evictAccount(account.id); accountStore.removeAccount(account.id); - fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); + fetch(apiPath(`/api/auth/session?slot=${account.cookieSlot}`), { method: 'DELETE' }).catch(() => {}); } } @@ -1417,7 +1418,7 @@ export const useAuthStore = create()( if (state.authMode === 'basic') { set({ isLoading: true, isRateLimited: false, rateLimitUntil: null }); try { - const res = await fetch('/api/auth/session', { method: 'PUT' }); + const res = await fetch(apiPath('/api/auth/session'), { method: 'PUT' }); if (res.ok) { const data = await res.json(); if (!data.serverUrl || !data.username || !data.password) { diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 2cf8c08b..0cc29523 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -7,6 +7,7 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { generateUUID } from '@/lib/utils'; +import { apiPath } from "@/lib/api-path"; export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; @@ -809,7 +810,7 @@ export const useCalendarStore = create()( if (!sub) return; try { - const response = await fetch('/api/fetch-ical', { + const response = await fetch(apiPath('/api/fetch-ical'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: sub.url }), diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index a7513c09..ca2bee4c 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -15,6 +15,7 @@ import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable import { setSlotRegistrationBridge } from '@/lib/plugin-api'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; import { usePolicyStore } from '@/stores/policy-store'; +import { apiPath } from "@/lib/api-path"; // ─── Slot State ────────────────────────────────────────────── @@ -363,7 +364,7 @@ async function syncServerPlugins( set: (partial: Partial | ((state: PluginStoreState) => Partial)) => void, ): Promise { try { - const res = await fetch('/api/plugins'); + const res = await fetch(apiPath('/api/plugins')); if (!res.ok) return; const data: { plugins: ServerPluginInfo[] } = await res.json(); @@ -484,7 +485,7 @@ async function syncServerPlugins( async function downloadPluginBundle(pluginId: string): Promise { try { - const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`); + const res = await fetch(apiPath(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`)); if (!res.ok) return null; return await res.text(); } catch { diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 0472512e..6a3a64fb 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; +import { apiPath } from "@/lib/api-path"; interface PolicyState { policy: SettingsPolicy; @@ -25,7 +26,7 @@ export const usePolicyStore = create()((set, get) => ({ fetchPolicy: async () => { try { - const res = await fetch('/api/admin/policy'); + const res = await fetch(apiPath('/api/admin/policy')); if (res.ok) { const data = await res.json(); set({ policy: data, loaded: true }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index e500a0a0..b4cce761 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import { useThemeStore } from './theme-store'; import { useLocaleStore } from './locale-store'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; +import { apiPath } from "@/lib/api-path"; // Use console directly to avoid circular dependency with lib/debug.ts // (debug.ts imports useSettingsStore for debugMode check) @@ -571,7 +572,7 @@ export const useSettingsStore = create()( loadFromServer: async (username: string, serverUrl: string) => { try { syncLog('Loading settings from server for', username); - const res = await fetch('/api/settings', { + const res = await fetch(apiPath('/api/settings'), { headers: { 'x-settings-username': username, 'x-settings-server': serverUrl, @@ -707,7 +708,7 @@ if (typeof window !== 'undefined') { const syncToServer = async (retries = 1): Promise => { const settings = JSON.parse(useSettingsStore.getState().exportSettings()); syncLog('Syncing settings to server...'); - const res = await fetch('/api/settings', { + const res = await fetch(apiPath('/api/settings'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }), diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 3ec62b26..1fa8634d 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -6,6 +6,7 @@ import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-lo import { extractTheme } from '@/lib/plugin-validator'; import { BUILTIN_THEMES } from '@/lib/builtin-themes'; import { usePolicyStore } from '@/stores/policy-store'; +import { apiPath } from "@/lib/api-path"; type Theme = 'light' | 'dark' | 'system'; @@ -299,7 +300,7 @@ export const useThemeStore = create()( themeSyncPromise = (async () => { try { - const res = await fetch('/api/plugins'); + const res = await fetch(apiPath('/api/plugins')); if (!res.ok) return; const data: { themes: ServerThemeInfo[] } = await res.json(); @@ -480,11 +481,11 @@ function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] { async function downloadThemeCSS(themeId: string): Promise { try { - const res = await fetch(`/api/admin/themes/${encodeURIComponent(themeId)}/css`); + const res = await fetch(apiPath(`/api/admin/themes/${encodeURIComponent(themeId)}/css`)); if (!res.ok) return null; return await res.text(); } catch { console.warn(`[theme-store] Failed to download CSS for theme "${themeId}"`); return null; } -} \ No newline at end of file +}