- Add NavigationRail component (desktop vertical icon sidebar + mobile bottom tab bar) - Add ConfirmDialog with promise-based useConfirmDialog hook for async confirmation flow - Add WelcomeBanner onboarding component (one-time display, localStorage persistence) - Polish login form UX (shake on error, TOTP slide animation, password visibility toggle, session expired banner) - Add inline form validation with shake animation in email composer and contacts - Add empty state patterns for contacts (no data vs no search results with contextual actions) - Improve toast notification system with undo action support and typed durations - Add WCAG AA prefers-reduced-motion media query, safe area insets, sr-only live regions - Add template settings tab and keyboard shortcut integration - Update all 8 locale translations
199 lines
6.4 KiB
TypeScript
199 lines
6.4 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 { useCalendarStore } from './calendar-store';
|
|
import { useFilterStore } from './filter-store';
|
|
import { debug } from '@/lib/debug';
|
|
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 {
|
|
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
|
await client.connect();
|
|
|
|
const identities = await client.getIdentities();
|
|
const primaryIdentity = identities.length > 0 ? identities[0] : null;
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (client.supportsCalendars()) {
|
|
const calendarStore = useCalendarStore.getState();
|
|
calendarStore.setSupported(true);
|
|
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
|
|
}
|
|
|
|
if (client.supportsSieve()) {
|
|
const filterStore = useFilterStore.getState();
|
|
filterStore.setSupported(true);
|
|
filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err));
|
|
}
|
|
|
|
// Success - save state (but NOT the password)
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl,
|
|
username,
|
|
client,
|
|
identities,
|
|
primaryIdentity,
|
|
error: null,
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
debug.error('Login error:', error);
|
|
let errorKey = 'generic';
|
|
|
|
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') ||
|
|
error.message.includes('NetworkError') ||
|
|
error.message.includes('ECONNREFUSED')) {
|
|
errorKey = 'connection_failed';
|
|
} else if (error.message.includes('500') ||
|
|
error.message.includes('502') ||
|
|
error.message.includes('503') ||
|
|
error.message.includes('504') ||
|
|
error.message.includes('Internal Server Error') ||
|
|
error.message.includes('Service Unavailable')) {
|
|
errorKey = 'server_error';
|
|
}
|
|
}
|
|
|
|
set({
|
|
isLoading: false,
|
|
error: errorKey,
|
|
isAuthenticated: false,
|
|
client: null,
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
logout: () => {
|
|
const state = get();
|
|
|
|
if (state.client) {
|
|
state.client.disconnect();
|
|
}
|
|
|
|
set({
|
|
isAuthenticated: false,
|
|
serverUrl: null,
|
|
username: null,
|
|
client: null,
|
|
identities: [],
|
|
primaryIdentity: null,
|
|
error: null,
|
|
});
|
|
|
|
localStorage.removeItem('auth-storage');
|
|
|
|
useEmailStore.setState({
|
|
emails: [],
|
|
mailboxes: [],
|
|
selectedEmail: null,
|
|
selectedMailbox: "",
|
|
isLoading: false,
|
|
error: null,
|
|
searchQuery: "",
|
|
quota: null,
|
|
});
|
|
|
|
useIdentityStore.getState().clearIdentities();
|
|
useContactStore.getState().clearContacts();
|
|
useVacationStore.getState().clearState();
|
|
useCalendarStore.getState().clearState();
|
|
useFilterStore.getState().clearState();
|
|
},
|
|
|
|
checkAuth: async () => {
|
|
const state = get();
|
|
|
|
if (state.isAuthenticated && !state.client) {
|
|
try {
|
|
sessionStorage.setItem('session_expired', 'true');
|
|
} catch { /* sessionStorage unavailable */ }
|
|
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
client: null,
|
|
serverUrl: null,
|
|
username: null,
|
|
});
|
|
}
|
|
|
|
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
|
|
}),
|
|
}
|
|
)
|
|
); |