feat: add Stalwart account security management

- Add Stalwart API client library (lib/stalwart/client.ts)
- Add server-side proxy routes for auth, crypto, password, principal, probe
- Add account security Zustand store with full state management
- Add Security settings tab with password change, display name, TOTP 2FA,
  app passwords, and encryption-at-rest controls
- Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false)
- Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt)
- Add tests for Stalwart client (24 tests) and security store (29 tests)
This commit is contained in:
Linus Rath
2026-03-12 02:20:56 +01:00
parent 68d4a9a641
commit ab72fc06ff
26 changed files with 3143 additions and 310 deletions
+27 -11
View File
@@ -6,6 +6,7 @@ type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
resolvedTheme: 'light' | 'dark';
hydrated: boolean;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
initializeTheme: () => void;
@@ -20,7 +21,6 @@ const applyTheme = (theme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Ensure both classes are handled properly
if (theme === 'dark') {
root.classList.remove('light');
root.classList.add('dark');
@@ -29,15 +29,20 @@ const applyTheme = (theme: 'light' | 'dark') => {
root.classList.add('light');
}
// Store in localStorage for immediate access
// Also update color-scheme for native elements (scrollbars, form controls)
root.style.colorScheme = theme;
localStorage.setItem('theme-applied', theme);
};
let mediaQueryCleanup: (() => void) | null = null;
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
resolvedTheme: 'light',
hydrated: false,
setTheme: (theme) => {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
@@ -57,9 +62,14 @@ export const useThemeStore = create<ThemeState>()(
const { theme } = get();
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ resolvedTheme });
set({ resolvedTheme, hydrated: true });
// Clean up previous listener if any
if (mediaQueryCleanup) {
mediaQueryCleanup();
mediaQueryCleanup = null;
}
// Listen for system theme changes
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
@@ -71,19 +81,25 @@ export const useThemeStore = create<ThemeState>()(
}
};
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQuery.addListener(handleChange);
}
mediaQuery.addEventListener('change', handleChange);
mediaQueryCleanup = () => mediaQuery.removeEventListener('change', handleChange);
}
},
}),
{
name: 'theme-storage',
partialize: (state) => ({ theme: state.theme }),
onRehydrateStorage: () => {
return (state) => {
if (state) {
// Re-apply theme immediately after rehydration
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
applyTheme(resolvedTheme);
state.resolvedTheme = resolvedTheme;
state.hydrated = true;
}
};
},
}
)
);