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).
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
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
|
|
isStalwart: boolean | null; // null = not yet probed
|
|
isProbing: boolean;
|
|
|
|
// Auth info
|
|
otpEnabled: boolean;
|
|
appPasswords: string[];
|
|
isLoadingAuth: boolean;
|
|
|
|
// Crypto info
|
|
encryptionType: string;
|
|
isLoadingCrypto: boolean;
|
|
|
|
// Principal info
|
|
displayName: string;
|
|
emails: string[];
|
|
quota: number;
|
|
roles: string[];
|
|
isLoadingPrincipal: boolean;
|
|
|
|
// Operation states
|
|
isSaving: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
probe: () => Promise<boolean>;
|
|
fetchAuthInfo: () => Promise<void>;
|
|
fetchCryptoInfo: () => Promise<void>;
|
|
fetchPrincipal: () => Promise<void>;
|
|
fetchAll: () => Promise<void>;
|
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
|
updateDisplayName: (displayName: string) => Promise<void>;
|
|
enableTotp: () => Promise<string>;
|
|
disableTotp: () => Promise<void>;
|
|
addAppPassword: (name: string, password: string) => Promise<void>;
|
|
removeAppPassword: (name: string) => Promise<void>;
|
|
updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise<void>;
|
|
clearState: () => void;
|
|
}
|
|
|
|
function getApiHeaders(): Record<string, string> {
|
|
return getActiveAccountSlotHeaders();
|
|
}
|
|
|
|
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
|
|
isStalwart: null,
|
|
isProbing: false,
|
|
otpEnabled: false,
|
|
appPasswords: [],
|
|
isLoadingAuth: false,
|
|
encryptionType: 'disabled',
|
|
isLoadingCrypto: false,
|
|
displayName: '',
|
|
emails: [],
|
|
quota: 0,
|
|
roles: [],
|
|
isLoadingPrincipal: false,
|
|
isSaving: false,
|
|
error: null,
|
|
|
|
probe: async () => {
|
|
set({ isProbing: true });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/probe', {
|
|
headers: getApiHeaders(),
|
|
});
|
|
const data = await response.json();
|
|
const isStalwart = data.isStalwart === true;
|
|
set({ isStalwart, isProbing: false });
|
|
return isStalwart;
|
|
} catch (error) {
|
|
debug.error('Stalwart probe failed:', error);
|
|
set({ isStalwart: false, isProbing: false });
|
|
return false;
|
|
}
|
|
},
|
|
|
|
fetchAuthInfo: async () => {
|
|
set({ isLoadingAuth: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/auth', {
|
|
headers: getApiHeaders(),
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const data = await response.json();
|
|
set({
|
|
otpEnabled: data.data?.otpEnabled ?? false,
|
|
appPasswords: data.data?.appPasswords ?? [],
|
|
isLoadingAuth: false,
|
|
});
|
|
} catch (error) {
|
|
debug.error('Failed to fetch auth info:', error);
|
|
set({
|
|
isLoadingAuth: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch auth info',
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchCryptoInfo: async () => {
|
|
set({ isLoadingCrypto: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/crypto', {
|
|
headers: getApiHeaders(),
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const data = await response.json();
|
|
set({
|
|
encryptionType: data.data?.type ?? 'disabled',
|
|
isLoadingCrypto: false,
|
|
});
|
|
} catch (error) {
|
|
debug.error('Failed to fetch crypto info:', error);
|
|
set({
|
|
isLoadingCrypto: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch crypto info',
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchPrincipal: async () => {
|
|
set({ isLoadingPrincipal: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/principal', {
|
|
headers: getApiHeaders(),
|
|
});
|
|
if (!response.ok) {
|
|
if (response.status === 403) {
|
|
// User lacks permission to read principal (e.g. non-admin); treat as empty
|
|
set({
|
|
displayName: '',
|
|
emails: [],
|
|
quota: 0,
|
|
roles: [],
|
|
isLoadingPrincipal: false,
|
|
});
|
|
return;
|
|
}
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
const principal = data.data;
|
|
set({
|
|
displayName: principal?.description ?? '',
|
|
emails: Array.isArray(principal?.emails) ? principal.emails : principal?.emails ? [principal.emails] : [],
|
|
quota: principal?.quota ?? 0,
|
|
roles: principal?.roles ?? [],
|
|
isLoadingPrincipal: false,
|
|
});
|
|
} catch (error) {
|
|
debug.error('Failed to fetch principal:', error);
|
|
set({
|
|
isLoadingPrincipal: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch principal',
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchAll: async () => {
|
|
const { fetchAuthInfo, fetchCryptoInfo, fetchPrincipal } = get();
|
|
await Promise.allSettled([fetchAuthInfo(), fetchCryptoInfo(), fetchPrincipal()]);
|
|
},
|
|
|
|
changePassword: async (currentPassword, newPassword) => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/password', {
|
|
method: 'POST',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ currentPassword, newPassword }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
set({ isSaving: false });
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to change password',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateDisplayName: async (displayName) => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/principal', {
|
|
method: 'PATCH',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([
|
|
{ action: 'set', field: 'description', value: displayName },
|
|
]),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
set({ displayName, isSaving: false });
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to update display name',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
enableTotp: async () => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/auth', {
|
|
method: 'POST',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
set({ otpEnabled: true, isSaving: false });
|
|
return data.data;
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to enable TOTP',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
disableTotp: async () => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/auth', {
|
|
method: 'POST',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
|
}
|
|
|
|
set({ otpEnabled: false, isSaving: false });
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to disable TOTP',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
addAppPassword: async (name, password) => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/auth', {
|
|
method: 'POST',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
|
}
|
|
|
|
// Refresh auth info to get updated app passwords list
|
|
await get().fetchAuthInfo();
|
|
set({ isSaving: false });
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to add app password',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
removeAppPassword: async (name) => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/auth', {
|
|
method: 'POST',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
|
}
|
|
|
|
// Refresh auth info to get updated app passwords list
|
|
await get().fetchAuthInfo();
|
|
set({ isSaving: false });
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to remove app password',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateEncryption: async (settings) => {
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
const response = await apiFetch('/api/account/stalwart/crypto', {
|
|
method: 'POST',
|
|
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(settings),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
|
}
|
|
|
|
set({ encryptionType: settings.type, isSaving: false });
|
|
} catch (error) {
|
|
set({
|
|
isSaving: false,
|
|
error: error instanceof Error ? error.message : 'Failed to update encryption',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
clearState: () => set({
|
|
isStalwart: null,
|
|
isProbing: false,
|
|
otpEnabled: false,
|
|
appPasswords: [],
|
|
isLoadingAuth: false,
|
|
encryptionType: 'disabled',
|
|
isLoadingCrypto: false,
|
|
displayName: '',
|
|
emails: [],
|
|
quota: 0,
|
|
roles: [],
|
|
isLoadingPrincipal: false,
|
|
isSaving: false,
|
|
error: null,
|
|
}),
|
|
}));
|