Files
SRCmail/stores/auth-store.ts
T
Matthieu MALVACHEandMatthieu MALVACHE fb414bf2c9 feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
2026-02-16 18:51:30 +01:00

189 lines
5.8 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
import type { Identity } from '@/lib/jmap/types';
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
identities: Identity[];
primaryIdentity: Identity | null;
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
isAuthenticated: false,
isLoading: false,
error: null,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
login: async (serverUrl, username, password, totp) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
set({ isLoading: true, error: null });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, effectivePassword);
// Try to connect
await client.connect();
// Fetch identities from the server
const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Sync identities to identity store
useIdentityStore.getState().setIdentities(identities);
// Fetch contacts if server supports JMAP Contacts
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
} else {
useContactStore.getState().setSupportsSync(false);
}
// Initialize vacation responder if supported
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
vacationStore.fetchVacationResponse(client).catch((err) => console.error('Failed to fetch vacation response:', err));
} else {
vacationStore.setSupported(false);
}
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
isLoading: false,
serverUrl,
username,
client,
identities,
primaryIdentity,
error: null,
});
return true;
} catch (error) {
console.error('Login error:', error);
let errorKey = 'generic';
// Map common errors to translation keys
if (error instanceof Error) {
if (error.message.includes('Invalid username or password') ||
error.message.includes('401') ||
error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials';
} else if (error.message.includes('network') ||
error.message.includes('Failed to fetch')) {
errorKey = 'connection_failed';
}
}
set({
isLoading: false,
error: errorKey,
isAuthenticated: false,
client: null,
});
return false;
}
},
logout: () => {
const state = get();
// Disconnect the JMAP client if it exists
if (state.client) {
state.client.disconnect();
}
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
error: null,
});
// Clear persisted storage
localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
error: null,
searchQuery: "",
quota: null,
});
// Clear identity store state
useIdentityStore.getState().clearIdentities();
// Clear contact store state
useContactStore.getState().clearContacts();
// Clear vacation store state
useVacationStore.getState().clearState();
},
checkAuth: async () => {
const state = get();
// If authenticated but no client (e.g., after page refresh), we can't restore the session
// because we don't store passwords for security reasons
if (state.isAuthenticated && !state.client) {
// Reset auth state - user will need to log in again
set({
isAuthenticated: false,
isLoading: false,
client: null,
serverUrl: null,
username: null,
});
}
// Mark loading as complete
set({ isLoading: false });
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
// Only persist non-sensitive data
serverUrl: state.serverUrl,
username: state.username,
// Don't persist isAuthenticated since we can't restore the session without a password
}),
}
)
);