From a7db3883aa1a4473d358153befcdf235f44aa099 Mon Sep 17 00:00:00 2001 From: shuki Date: Tue, 14 Apr 2026 00:18:44 +0300 Subject: [PATCH] feat: apiFetch helper for mount-prefix-aware API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes every client-side fetch('/api/...') call respect the mount prefix when Bulwark is served behind a reverse proxy at a sub-path (e.g. `/webmail`). ### Problem `getPathPrefix()` (added in 1.4.13 by #XXX / d762b94) already fixes router navigation and redirect URIs for reverse-proxy deployments. Client-side `fetch()` calls, though, still target the browser origin: await fetch('/api/foo') // Browser at /webmail/en/inbox → hits /api/foo (not proxied → 404) That means the login flow, session establishment, settings save, plugin loader, calendar import, etc. all break the moment you front Bulwark with nginx (or any proxy) at a sub-path. ### Fix Add `apiFetch(input, init)` next to `getPathPrefix()` in `lib/browser-navigation.ts`. It prepends the mount prefix to any absolute path at call time: await apiFetch('/api/foo') // /webmail/en/inbox → /webmail/api/foo // /en/inbox → /api/foo Same runtime-detection model as `getPathPrefix()` — the built bundle works at any mount point without rebuilding or env-var config. Protocol-relative (`//cdn...`) and absolute (`https://...`) URLs pass through unchanged. Server-side route handlers are untouched (the mount prefix is a browser-only concept). ### Migration Mechanical rewrite of every client-side `fetch('/api/...')` call in hooks/, lib/, stores/, components/, app/ — 99 call sites across 26 files. `route.ts` handlers and other server-only files are skipped. ### Compat - No behaviour change when mounted at `/` (the common case): an empty prefix + raw path is identical to raw path. - No new config knobs, env vars, or build flags. - Supersedes PR #181 (which required a build-time `NEXT_PUBLIC_BASE_PATH`) — will close #181 after this lands. ### Testing Should run the existing suite; smoke-tested by Jabali Panel which reverse-proxies Bulwark at `/webmail/` (https://github.com/shukiv/jabali-panel). --- app/[locale]/login/page.tsx | 4 +- 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/browser-navigation.ts | 29 ++++++++++++++ lib/plugin-api.ts | 9 +++-- stores/account-security-store.ts | 23 ++++++----- stores/auth-store.ts | 40 +++++++++---------- stores/calendar-store.ts | 3 +- stores/plugin-store.ts | 5 ++- stores/policy-store.ts | 3 +- stores/settings-store.ts | 5 ++- stores/theme-store.ts | 5 ++- 27 files changed, 154 insertions(+), 101 deletions(-) diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index b3561a74..5b79c442 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -10,7 +10,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; -import { getPathPrefix } from "@/lib/browser-navigation"; +import { apiFetch, getPathPrefix } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; @@ -234,7 +234,7 @@ export default function LoginPage() { try { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; - const res = await fetch('/api/auth/sso/start', { + const res = await apiFetch('/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..54006784 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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..64cad0d6 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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..f3f730ce 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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..305b47d4 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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..c1e1e752 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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..9a8d6462 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch(`/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..1a547953 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch(`/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 apiFetch('/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..db5808e0 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 { apiFetch } from '@/lib/browser-navigation'; 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), + apiFetch('/api/admin/auth'), + apiFetch('/api/admin/audit?limit=10'), + apiFetch('/api/config'), + apiFetch('/api/admin/config'), + apiFetch('/api/admin/plugins').catch(() => null), + apiFetch('/api/admin/themes').catch(() => null), + apiFetch('/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 apiFetch('/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..1411b444 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 { apiFetch } from '@/lib/browser-navigation'; 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`), + apiFetch('/api/admin/plugins'), + apiFetch(`/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 apiFetch(`/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 apiFetch(`/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..7bede6a4 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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..0204b2d4 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 { apiFetch } from '@/lib/browser-navigation'; // 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 apiFetch('/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 apiFetch('/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..f6aff034 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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..78972cac 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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..3324fb7a 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 { apiFetch } from "@/lib/browser-navigation"; 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 apiFetch("/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..d413f57b 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 { apiFetch } from "@/lib/browser-navigation"; 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 }) + apiFetch('/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', { + apiFetch('/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..9a5151e4 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 { apiFetch } from '@/lib/browser-navigation'; const CALENDAR_COLORS = [ "#3b82f6", // blue @@ -202,7 +203,7 @@ export function CalendarManagementSettings() { const controller = new AbortController(); - fetch('/api/caldav/discover', { + apiFetch('/api/caldav/discover', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/hooks/use-config.ts b/hooks/use-config.ts index c884ea05..652a2c4c 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 { apiFetch } from '@/lib/browser-navigation'; interface ConfigData { appName: string; @@ -50,7 +51,7 @@ export async function fetchConfig(): Promise { } // Start a new fetch - configPromise = fetch('/api/config') + configPromise = apiFetch('/api/config') .then((res) => { if (!res.ok) { throw new Error('Failed to fetch config'); diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts index 59be2d35..7f5a2c11 100644 --- a/lib/browser-navigation.ts +++ b/lib/browser-navigation.ts @@ -36,6 +36,35 @@ export function getPathPrefix(locale?: string): string { return '/' + segments.slice(0, localeIndex).join('/'); } +/** + * Mount-prefix-aware wrapper around `fetch()`. + * + * When Bulwark is served behind a reverse proxy at a sub-path (e.g. `/bulwark`), + * `fetch('/api/foo')` would target the browser origin at `/api/foo`, which the + * proxy doesn't route. `apiFetch` detects the mount prefix from + * `window.location.pathname` via `getPathPrefix()` at call time, so the same + * built bundle works at any mount point without rebuilding. + * + * Only rewrites absolute paths that start with a single `/`. Protocol-relative + * URLs (`//cdn.example.com/foo`) and absolute URLs (`https://...`) pass + * through unchanged. + * + * Server code (route handlers, layout files running at SSR) should keep using + * the raw Fetch API — the mount prefix is a browser-only concept. + * + * @example + * await apiFetch('/api/jmap', { method: 'POST', body }) + * // Browser at /webmail/en/inbox → /webmail/api/jmap + * // Browser at /en/inbox → /api/jmap + */ +export function apiFetch(input: string, init?: RequestInit): Promise { + if (input.startsWith('/') && !input.startsWith('//')) { + return fetch(getPathPrefix() + input, init); + } + return fetch(input, init); +} + + /** * Extracts the locale from the current URL, skipping any mount prefix. * Falls back to 'en' when no known locale segment is found. diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 4ca7144e..deb9a0ce 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 { apiFetch } from '@/lib/browser-navigation'; // --- Permission helpers -------------------------------------- @@ -682,20 +683,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 apiFetch(`/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 apiFetch(`/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 apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }), @@ -703,7 +704,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 apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index 405d55d9..b811cfe9 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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 apiFetch('/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..6a643b71 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -12,7 +12,7 @@ import { useAccountStore } from './account-store'; import { fetchConfig } from '@/hooks/use-config'; import { debug } from '@/lib/debug'; import { generateAccountId } from '@/lib/account-utils'; -import { replaceWindowLocation, getPathPrefix, getLocaleFromPath } from '@/lib/browser-navigation'; +import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; @@ -95,7 +95,7 @@ async function syncStalwartAuthContext( slot: number, ): Promise { try { - const response = await fetch('/api/auth/stalwart-context', { + const response = await apiFetch('/api/auth/stalwart-context', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl, username, authHeader, slot }), @@ -403,7 +403,7 @@ export const useAuthStore = create()( if (totp) { try { - const tokenRes = await fetch('/api/auth/totp-token-exchange', { + const tokenRes = await apiFetch('/api/auth/totp-token-exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), @@ -470,7 +470,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 apiFetch(`/api/auth/session?slot=${cookieSlot}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), @@ -607,7 +607,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 apiFetch(`/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 +718,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 apiFetch('/api/auth/sso/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -841,7 +841,7 @@ export const useAuthStore = create()( const promise = (async () => { try { - const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' }); + const res = await apiFetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' }); if (!res.ok) { notifyParent('sso:session-expired'); @@ -963,9 +963,9 @@ export const useAuthStore = create()( } // Background cookie cleanup for the removed account - fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); if (wasOAuth) { - fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); } return; } @@ -977,9 +977,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(() => {}); + apiFetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); if (wasOAuth) { - fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + apiFetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {}); } } @@ -1006,8 +1006,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(() => {}); + apiFetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); + apiFetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); redirectToLogin(); }, @@ -1041,7 +1041,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 apiFetch(`/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 +1058,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 apiFetch(`/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 +1107,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(() => {}); + apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); // Restore the previous account if still available if (state.activeAccountId && state.activeAccountId !== accountId) { @@ -1210,7 +1210,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 apiFetch(`/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 +1225,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 apiFetch(`/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 +1255,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(() => {}); + apiFetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); } } @@ -1417,7 +1417,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 apiFetch('/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..71cb3ea3 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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..36686d62 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 { apiFetch } from '@/lib/browser-navigation'; // ─── 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 apiFetch('/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 apiFetch(`/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..bcb3a87f 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/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 8a5f5fb9..798034bf 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 { apiFetch } from '@/lib/browser-navigation'; // Use console directly to avoid circular dependency with lib/debug.ts // (debug.ts imports useSettingsStore for debugMode check) @@ -611,7 +612,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 apiFetch('/api/settings', { headers: { 'x-settings-username': username, 'x-settings-server': serverUrl, @@ -747,7 +748,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 apiFetch('/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..342746cb 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 { apiFetch } from '@/lib/browser-navigation'; 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 apiFetch('/api/plugins'); if (!res.ok) return; const data: { themes: ServerThemeInfo[] } = await res.json(); @@ -480,7 +481,7 @@ 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 apiFetch(`/api/admin/themes/${encodeURIComponent(themeId)}/css`); if (!res.ok) return null; return await res.text(); } catch {