feat: Phase 3+4 — security hardening + polish + offline + Electron push
Phase 3 (security): - P3.1: Feature gate server-side enforcement (403 on disabled features) - P3.2: Unified auth error interceptor (401→logout) - P3.3: Store-level state isolation via StoreSnapshot contract (added message-list-tabs + task stores to snapshot/restore cycle) - P3.4: Push event bus extraction — email-store no longer imports calendar/contact/filter/file stores directly - P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter Phase 4 (polish): - P4.1: Offline write queue — pending operations in localStorage, auto-retry on reconnect, offline-queue-indicator banner - P4.2: Identity spoofing — fromOverrideEmail domain validation - P4.3: WebSocket push for Electron via main-process IPC bridge (ws package with Authorization headers)
This commit is contained in:
+38
-9
@@ -5,6 +5,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { getClientByLocalAccountId } from './client-registry';
|
||||
import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
/** One connected JMAP account for contact multi-account aggregation. */
|
||||
export interface ContactAccountClient {
|
||||
@@ -375,12 +377,12 @@ export const useContactStore = create<ContactStore>()(
|
||||
|
||||
createContact: async (client, contact) => {
|
||||
set({ isLoading: true, error: null });
|
||||
let accountId: string | undefined = contact.isShared ? contact.accountId : undefined;
|
||||
let cleanedContact = contact;
|
||||
try {
|
||||
// Determine target account from the selected address book. Also
|
||||
// pin the local account so we route through the right server's
|
||||
// client in multi-account Pro mode.
|
||||
let accountId = contact.isShared ? contact.accountId : undefined;
|
||||
let cleanedContact = contact;
|
||||
let localAccountId = contact.localAccountId;
|
||||
|
||||
// De-namespace addressBookIds if they reference a shared address book
|
||||
@@ -424,6 +426,12 @@ export const useContactStore = create<ContactStore>()(
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create contact';
|
||||
if (isNetworkError(error)) {
|
||||
const queueAccountId = accountId || useAccountStore.getState().activeAccountId;
|
||||
if (queueAccountId) {
|
||||
enqueueOperation({ type: 'createContact', accountId: queueAccountId, payload: cleanedContact });
|
||||
}
|
||||
}
|
||||
set({ error: msg, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
@@ -431,14 +439,14 @@ export const useContactStore = create<ContactStore>()(
|
||||
|
||||
updateContact: async (client, id, updates) => {
|
||||
set({ error: null });
|
||||
const contact = get().contacts.find(c => c.id === id);
|
||||
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||
let cleanedUpdates = updates;
|
||||
try {
|
||||
const contact = get().contacts.find(c => c.id === id);
|
||||
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||
client = resolveAccountClient(client, contact?.localAccountId);
|
||||
|
||||
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
||||
let cleanedUpdates = updates;
|
||||
if (contact?.isShared && contact?.accountId && updates.addressBookIds) {
|
||||
const prefix = `${contact.accountId}:`;
|
||||
const deNamespaced = Object.fromEntries(
|
||||
@@ -458,6 +466,12 @@ export const useContactStore = create<ContactStore>()(
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to update contact';
|
||||
if (isNetworkError(error)) {
|
||||
const queueAccountId = accountId || useAccountStore.getState().activeAccountId;
|
||||
if (queueAccountId) {
|
||||
enqueueOperation({ type: 'updateContact', accountId: queueAccountId, payload: { id: originalId, updates: cleanedUpdates } });
|
||||
}
|
||||
}
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
@@ -465,10 +479,10 @@ export const useContactStore = create<ContactStore>()(
|
||||
|
||||
deleteContact: async (client, id) => {
|
||||
set({ error: null });
|
||||
const contact = get().contacts.find(c => c.id === id);
|
||||
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||
try {
|
||||
const contact = get().contacts.find(c => c.id === id);
|
||||
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
|
||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||
client = resolveAccountClient(client, contact?.localAccountId);
|
||||
await client.deleteContact(originalId, accountId);
|
||||
set((state) => {
|
||||
@@ -481,6 +495,12 @@ export const useContactStore = create<ContactStore>()(
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
||||
if (isNetworkError(error)) {
|
||||
const queueAccountId = accountId || useAccountStore.getState().activeAccountId;
|
||||
if (queueAccountId) {
|
||||
enqueueOperation({ type: 'deleteContact', accountId: queueAccountId, payload: { id: originalId, targetAccountId: accountId } });
|
||||
}
|
||||
}
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
@@ -1143,4 +1163,13 @@ export const useContactStore = create<ContactStore>()(
|
||||
)
|
||||
);
|
||||
|
||||
import { registerPushHandler } from '@/lib/push-event-bus';
|
||||
|
||||
registerPushHandler('ContactCard', async (client) => {
|
||||
const store = useContactStore.getState();
|
||||
store.fetchContacts(client).catch((err) => {
|
||||
console.error('Failed to refresh contacts on push:', err);
|
||||
});
|
||||
});
|
||||
|
||||
export type { ContactName };
|
||||
|
||||
Reference in New Issue
Block a user