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
@@ -0,0 +1,424 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAccountSecurityStore } from '../account-security-store';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const defaultState = {
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
isSaving: false,
error: null,
};
describe('AccountSecurityStore', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
useAccountSecurityStore.setState(defaultState);
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('probe', () => {
it('sets isStalwart to true when probe succeeds', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: true }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(true);
expect(useAccountSecurityStore.getState().isStalwart).toBe(true);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
it('sets isStalwart to false when probe returns false', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: false }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
});
it('sets isStalwart to false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
});
describe('fetchAuthInfo', () => {
it('populates auth info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: true, appPasswords: ['app1', 'app2'] } })
);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(true);
expect(state.appPasswords).toEqual(['app1', 'app2']);
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBeNull();
});
it('sets defaults when data fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
});
it('sets error on HTTP failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('HTTP 500');
});
it('sets error on network failure', async () => {
fetchSpy.mockRejectedValueOnce(new Error('Connection refused'));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('Connection refused');
});
});
describe('fetchCryptoInfo', () => {
it('populates crypto info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { type: 'pgp' } })
);
await useAccountSecurityStore.getState().fetchCryptoInfo();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.isLoadingCrypto).toBe(false);
});
it('defaults to disabled when type is missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
});
it('sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403));
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().error).toBe('HTTP 403');
});
});
describe('fetchPrincipal', () => {
it('populates principal info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: {
description: 'John Doe',
emails: ['john@example.com', 'doe@example.com'],
quota: 5000000,
roles: ['user', 'admin'],
},
})
);
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('John Doe');
expect(state.emails).toEqual(['john@example.com', 'doe@example.com']);
expect(state.quota).toBe(5000000);
expect(state.roles).toEqual(['user', 'admin']);
expect(state.isLoadingPrincipal).toBe(false);
});
it('handles single email string as array', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: { description: 'User', emails: 'single@example.com', quota: 0, roles: [] },
})
);
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual(['single@example.com']);
});
it('handles missing emails gracefully', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { description: 'User' } })
);
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual([]);
});
it('sets defaults when fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
});
});
describe('fetchAll', () => {
it('calls all three fetch methods in parallel', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'smime' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'Test', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('smime');
expect(state.displayName).toBe('Test');
expect(state.isLoadingAuth).toBe(false);
expect(state.isLoadingCrypto).toBe(false);
expect(state.isLoadingPrincipal).toBe(false);
});
it('continues even if one fetch fails', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(500)) // auth fails
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'pgp' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'OK', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.displayName).toBe('OK');
});
});
describe('changePassword', () => {
it('sends POST with currentPassword and newPassword', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { ok: true }));
await useAccountSecurityStore.getState().changePassword('oldpass', 'newpass123');
expect(fetchSpy).toHaveBeenCalledWith('/api/account/stalwart/password', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass123' }),
}));
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { error: 'Current password is incorrect' }));
await expect(
useAccountSecurityStore.getState().changePassword('wrong', 'newpass123')
).rejects.toThrow('Current password is incorrect');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect');
});
});
describe('updateDisplayName', () => {
it('sends PATCH and updates local state on success', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().updateDisplayName('New Name');
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('New Name');
expect(state.isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server error' }));
await expect(
useAccountSecurityStore.getState().updateDisplayName('Name')
).rejects.toThrow('Server error');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
const result = await useAccountSecurityStore.getState().enableTotp();
expect(result).toBe(totpUrl);
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'enableOtpAuth' }]);
});
it('throws and preserves otpEnabled=false on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'TOTP error' }));
await expect(
useAccountSecurityStore.getState().enableTotp()
).rejects.toThrow('TOTP error');
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth and sets otpEnabled to false', async () => {
useAccountSecurityStore.setState({ otpEnabled: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().disableTotp();
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
});
describe('addAppPassword', () => {
it('sends addAppPassword and refreshes auth info', async () => {
// First call: POST addAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['Thunderbird'] } })
);
await useAccountSecurityStore.getState().addAppPassword('Thunderbird', 'secret');
const state = useAccountSecurityStore.getState();
expect(state.appPasswords).toEqual(['Thunderbird']);
expect(state.isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret' }]);
});
it('throws on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server down' }));
await expect(
useAccountSecurityStore.getState().addAppPassword('App', 'pass')
).rejects.toThrow('Server down');
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword and refreshes auth info', async () => {
useAccountSecurityStore.setState({ appPasswords: ['Thunderbird', 'iPhone'] });
// First call: POST removeAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['iPhone'] } })
);
await useAccountSecurityStore.getState().removeAppPassword('Thunderbird');
expect(useAccountSecurityStore.getState().appPasswords).toEqual(['iPhone']);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('updateEncryption', () => {
it('sends crypto settings and updates local encryptionType', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' });
expect(useAccountSecurityStore.getState().encryptionType).toBe('pgp');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
it('throws on failure without changing encryptionType', async () => {
useAccountSecurityStore.setState({ encryptionType: 'disabled' });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Encryption error' }));
await expect(
useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' })
).rejects.toThrow('Encryption error');
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
});
});
describe('clearState', () => {
it('resets all state to defaults', () => {
useAccountSecurityStore.setState({
isStalwart: true,
otpEnabled: true,
appPasswords: ['app1'],
encryptionType: 'pgp',
displayName: 'Test User',
emails: ['test@example.com'],
quota: 5000000,
roles: ['admin'],
error: 'some error',
});
useAccountSecurityStore.getState().clearState();
const state = useAccountSecurityStore.getState();
expect(state.isStalwart).toBeNull();
expect(state.isProbing).toBe(false);
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
expect(state.encryptionType).toBe('disabled');
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
expect(state.isSaving).toBe(false);
expect(state.error).toBeNull();
});
});
});
+353
View File
@@ -0,0 +1,353 @@
import { create } from 'zustand';
import { debug } from '@/lib/debug';
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;
}
/**
* Get authorization headers for API requests.
* Returns headers object with auth credentials.
*/
function getApiHeaders(): Record<string, string> {
// We rely on the proxy routes which read from session cookie
// No additional headers needed for cookie-based auth
return {};
}
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 fetch('/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 fetch('/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 fetch('/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 fetch('/api/account/stalwart/principal', {
headers: getApiHeaders(),
});
if (!response.ok) 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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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 fetch('/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,
}),
}));
+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;
}
};
},
}
)
);