fix: account isolation, auto-import signer certs, and no-key error handling #35
This commit is contained in:
@@ -908,7 +908,7 @@ export function EmailViewer({
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode } = useAuthStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -962,9 +962,9 @@ export function EmailViewer({
|
||||
|
||||
// Ensure S/MIME key records are loaded from IndexedDB
|
||||
useLayoutEffect(() => {
|
||||
smimeStore.load();
|
||||
smimeStore.load(activeAccountId ?? undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [activeAccountId]);
|
||||
|
||||
// Build mailbox tree for move-to dropdown
|
||||
const moveTargetIds = useMemo(() => new Set(
|
||||
@@ -1544,6 +1544,18 @@ export function EmailViewer({
|
||||
// Encrypted message
|
||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
||||
|
||||
// Short-circuit: no keys imported at all
|
||||
if (keyRecords.length === 0) {
|
||||
smimeDebug('[S/MIME] no key records available, skipping decrypt');
|
||||
setSmimeStatus({
|
||||
isSigned: false,
|
||||
isEncrypted: true,
|
||||
decryptionError: 'no-key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||
let lastError: unknown = null;
|
||||
@@ -1635,8 +1647,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
||||
} catch { /* ignore import errors */ }
|
||||
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
|
||||
} catch (importErr) {
|
||||
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||
}
|
||||
} else {
|
||||
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||
}
|
||||
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
|
||||
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
|
||||
}
|
||||
} catch (error) {
|
||||
smimeError('[S/MIME] nested signature verify failed:', {
|
||||
@@ -1674,10 +1693,12 @@ export function EmailViewer({
|
||||
decryptionError: 'locked',
|
||||
});
|
||||
} else {
|
||||
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||
setSmimeStatus({
|
||||
isSigned: false,
|
||||
isEncrypted: true,
|
||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
||||
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1739,8 +1760,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
||||
} catch { /* ignore import errors */ }
|
||||
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
|
||||
} catch (importErr) {
|
||||
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||
}
|
||||
} else {
|
||||
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||
}
|
||||
} else if (result.status.signatureValid && result.status.signerCert) {
|
||||
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.decryptionError === 'no-key') {
|
||||
items.push({
|
||||
icon: <Lock className="w-4 h-4" />,
|
||||
text: t('status_encrypted_no_key'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-4 h-4" />,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
|
||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
@@ -50,6 +51,7 @@ export function SmimeSettings() {
|
||||
} = useSmimeStore();
|
||||
|
||||
const { identities } = useIdentityStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
// Local UI state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
@@ -75,8 +77,8 @@ export function SmimeSettings() {
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
load(activeAccountId ?? undefined);
|
||||
}, [load, activeAccountId]);
|
||||
|
||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useFilterStore } from '@/stores/filter-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
|
||||
// Minimal snapshot shapes — we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
useSmimeStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** Evict cached state for one account */
|
||||
|
||||
@@ -148,4 +148,50 @@ describe('key-storage', () => {
|
||||
expect(certs.find(c => c.id === id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountId filtering', () => {
|
||||
it('listKeyRecords filters by accountId', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||
|
||||
const acct1Records = await listKeyRecords('acct-1');
|
||||
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||
expect(acct1Records.find(r => r.id === id2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('listKeyRecords includes records without accountId when filtering', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` }));
|
||||
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||
|
||||
const acct1Records = await listKeyRecords('acct-1');
|
||||
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||
expect(acct1Records.find(r => r.id === id2)).toBeDefined();
|
||||
});
|
||||
|
||||
it('listPublicCerts filters by accountId', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||
|
||||
const acct1Certs = await listPublicCerts('acct-1');
|
||||
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||
expect(acct1Certs.find(c => c.id === id2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('listPublicCerts includes certs without accountId when filtering', async () => {
|
||||
const id1 = uid();
|
||||
const id2 = uid();
|
||||
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` }));
|
||||
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||
|
||||
const acct1Certs = await listPublicCerts('acct-1');
|
||||
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||
expect(acct1Certs.find(c => c.id === id2)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,9 @@ beforeEach(() => {
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: false,
|
||||
autoImportSignerCerts: true,
|
||||
accountPreferences: {},
|
||||
currentAccountId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
||||
|
||||
const DB_NAME = 'smime-store';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
const KEY_RECORDS_STORE = 'key-records';
|
||||
const PUBLIC_CERTS_STORE = 'public-certs';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
|
||||
const oldVersion = event.oldVersion;
|
||||
if (oldVersion < 1) {
|
||||
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
||||
keyStore.createIndex('email', 'email', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
|
||||
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
||||
certStore.createIndex('email', 'email', { unique: false });
|
||||
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
if (oldVersion >= 1 && oldVersion < 2) {
|
||||
// Add accountId index to existing stores
|
||||
const tx = request.transaction!;
|
||||
const keyStore = tx.objectStore(KEY_RECORDS_STORE);
|
||||
if (!keyStore.indexNames.contains('accountId')) {
|
||||
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
const certStore = tx.objectStore(PUBLIC_CERTS_STORE);
|
||||
if (!certStore.indexNames.contains('accountId')) {
|
||||
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
@@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecor
|
||||
});
|
||||
}
|
||||
|
||||
export async function listKeyRecords(): Promise<SmimeKeyRecord[]> {
|
||||
export async function listKeyRecords(accountId?: string): Promise<SmimeKeyRecord[]> {
|
||||
const db = await openDB();
|
||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
||||
const all = await txPromise<SmimeKeyRecord[]>(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
||||
if (!accountId) return all;
|
||||
return all.filter((r) => r.accountId === accountId || !r.accountId);
|
||||
}
|
||||
|
||||
export async function deleteKeyRecord(id: string): Promise<void> {
|
||||
@@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise<SmimePublicC
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPublicCerts(): Promise<SmimePublicCert[]> {
|
||||
export async function listPublicCerts(accountId?: string): Promise<SmimePublicCert[]> {
|
||||
const db = await openDB();
|
||||
return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
||||
const all = await txPromise<SmimePublicCert[]>(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
||||
if (!accountId) return all;
|
||||
return all.filter((c) => c.accountId === accountId || !c.accountId);
|
||||
}
|
||||
|
||||
export async function deletePublicCert(id: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Stored record for an imported S/MIME private key + certificate. */
|
||||
export interface SmimeKeyRecord {
|
||||
id: string;
|
||||
accountId?: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
||||
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
||||
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
|
||||
/** A recipient or contact public certificate. */
|
||||
export interface SmimePublicCert {
|
||||
id: string;
|
||||
accountId?: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509
|
||||
issuer: string;
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "S/MIME-Signatur deaktiviert",
|
||||
"smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert",
|
||||
"smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert",
|
||||
"smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich: {reason}",
|
||||
"smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich – fehlende Empfängerzertifikate",
|
||||
"smime_unlock_title": "S/MIME-Schlüssel entsperren",
|
||||
"smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.",
|
||||
"smime_unlock_button": "Entsperren",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "Firma S/MIME desactivada",
|
||||
"smime_encrypt_on": "Cifrado S/MIME activado",
|
||||
"smime_encrypt_off": "Cifrado S/MIME desactivado",
|
||||
"smime_encrypt_unavailable": "El cifrado S/MIME no está disponible: {reason}",
|
||||
"smime_encrypt_unavailable": "Cifrado S/MIME no disponible – faltan certificados de destinatario",
|
||||
"smime_unlock_title": "Desbloquear clave S/MIME",
|
||||
"smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.",
|
||||
"smime_unlock_button": "Desbloquear",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "Signature S/MIME désactivée",
|
||||
"smime_encrypt_on": "Chiffrement S/MIME activé",
|
||||
"smime_encrypt_off": "Chiffrement S/MIME désactivé",
|
||||
"smime_encrypt_unavailable": "Le chiffrement S/MIME n'est pas disponible : {reason}",
|
||||
"smime_encrypt_unavailable": "Chiffrement S/MIME indisponible – certificats des destinataires manquants",
|
||||
"smime_unlock_title": "Déverrouiller la clé S/MIME",
|
||||
"smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.",
|
||||
"smime_unlock_button": "Déverrouiller",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "Firma S/MIME disattivata",
|
||||
"smime_encrypt_on": "Cifratura S/MIME attivata",
|
||||
"smime_encrypt_off": "Cifratura S/MIME disattivata",
|
||||
"smime_encrypt_unavailable": "La cifratura S/MIME non è disponibile: {reason}",
|
||||
"smime_encrypt_unavailable": "Cifratura S/MIME non disponibile – certificati destinatario mancanti",
|
||||
"smime_unlock_title": "Sblocca chiave S/MIME",
|
||||
"smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.",
|
||||
"smime_unlock_button": "Sblocca",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "S/MIME 署名を無効化しました",
|
||||
"smime_encrypt_on": "S/MIME 暗号化を有効化しました",
|
||||
"smime_encrypt_off": "S/MIME 暗号化を無効化しました",
|
||||
"smime_encrypt_unavailable": "S/MIME 暗号化は利用できません: {reason}",
|
||||
"smime_encrypt_unavailable": "S/MIME 暗号化は利用できません – 受信者の証明書がありません",
|
||||
"smime_unlock_title": "S/MIME 鍵のロックを解除",
|
||||
"smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。",
|
||||
"smime_unlock_button": "ロック解除",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "S/MIME-ondertekening uitgeschakeld",
|
||||
"smime_encrypt_on": "S/MIME-versleuteling ingeschakeld",
|
||||
"smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld",
|
||||
"smime_encrypt_unavailable": "S/MIME-versleuteling is niet beschikbaar: {reason}",
|
||||
"smime_encrypt_unavailable": "S/MIME-versleuteling niet beschikbaar – ontbrekende ontvangercertificaten",
|
||||
"smime_unlock_title": "S/MIME-sleutel ontgrendelen",
|
||||
"smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.",
|
||||
"smime_unlock_button": "Ontgrendelen",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"smime_sign_off": "Assinatura S/MIME desativada",
|
||||
"smime_encrypt_on": "Criptografia S/MIME ativada",
|
||||
"smime_encrypt_off": "Criptografia S/MIME desativada",
|
||||
"smime_encrypt_unavailable": "A criptografia S/MIME não está disponível: {reason}",
|
||||
"smime_encrypt_unavailable": "Criptografia S/MIME indisponível – certificados de destinatário ausentes",
|
||||
"smime_unlock_title": "Desbloquear chave S/MIME",
|
||||
"smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.",
|
||||
"smime_unlock_button": "Desbloquear",
|
||||
|
||||
+110
-20
@@ -123,14 +123,23 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
||||
}
|
||||
|
||||
interface SmimePersistedState {
|
||||
identityKeyBindings: Record<string, string>; // identityId → keyRecordId
|
||||
defaultSignIdentity: Record<string, boolean>; // identityId → sign by default
|
||||
defaultEncrypt: boolean;
|
||||
/** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */
|
||||
accountPreferences: Record<string, {
|
||||
identityKeyBindings: Record<string, string>;
|
||||
defaultSignIdentity: Record<string, boolean>;
|
||||
defaultEncrypt: boolean;
|
||||
}>;
|
||||
rememberUnlockedKeys: boolean;
|
||||
autoImportSignerCerts: boolean;
|
||||
}
|
||||
|
||||
interface SmimeStore extends SmimePersistedState {
|
||||
// Current account scope
|
||||
currentAccountId: string | null;
|
||||
// Account-scoped convenience accessors (derived from accountPreferences + currentAccountId)
|
||||
identityKeyBindings: Record<string, string>;
|
||||
defaultSignIdentity: Record<string, boolean>;
|
||||
defaultEncrypt: boolean;
|
||||
// Loaded from IndexedDB
|
||||
keyRecords: SmimeKeyRecord[];
|
||||
publicCerts: SmimePublicCert[];
|
||||
@@ -141,7 +150,8 @@ interface SmimeStore extends SmimePersistedState {
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
load: () => Promise<void>;
|
||||
load: (accountId?: string) => Promise<void>;
|
||||
clearState: () => void;
|
||||
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
|
||||
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
|
||||
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
|
||||
@@ -166,13 +176,15 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Persisted preferences
|
||||
accountPreferences: {},
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: true,
|
||||
|
||||
// Runtime state
|
||||
currentAccountId: null,
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: false,
|
||||
|
||||
// Runtime state
|
||||
keyRecords: [],
|
||||
publicCerts: [],
|
||||
unlockedKeys: new Map(),
|
||||
@@ -180,12 +192,30 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
load: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
load: async (accountId) => {
|
||||
const acctId = accountId ?? get().currentAccountId;
|
||||
set({ isLoading: true, error: null, currentAccountId: acctId });
|
||||
|
||||
// Restore account-scoped preferences
|
||||
const prefs = acctId ? get().accountPreferences[acctId] : undefined;
|
||||
if (prefs) {
|
||||
set({
|
||||
identityKeyBindings: prefs.identityKeyBindings,
|
||||
defaultSignIdentity: prefs.defaultSignIdentity,
|
||||
defaultEncrypt: prefs.defaultEncrypt,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const [keyRecords, publicCerts] = await Promise.all([
|
||||
listKeyRecords(),
|
||||
listPublicCerts(),
|
||||
listKeyRecords(acctId ?? undefined),
|
||||
listPublicCerts(acctId ?? undefined),
|
||||
]);
|
||||
|
||||
if (get().rememberUnlockedKeys) {
|
||||
@@ -219,6 +249,8 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase);
|
||||
const acctId = get().currentAccountId;
|
||||
if (acctId) keyRecord.accountId = acctId;
|
||||
await saveKeyRecord(keyRecord);
|
||||
set((state) => ({
|
||||
keyRecords: [...state.keyRecords, keyRecord],
|
||||
@@ -245,6 +277,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
|
||||
const publicCert: SmimePublicCert = {
|
||||
id: crypto.randomUUID(),
|
||||
accountId: get().currentAccountId ?? undefined,
|
||||
email: email.toLowerCase(),
|
||||
certificate: der,
|
||||
issuer: info.issuer,
|
||||
@@ -279,7 +312,15 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
} else {
|
||||
bindings[identityId] = keyRecordId;
|
||||
}
|
||||
return { identityKeyBindings: bindings };
|
||||
const accountPreferences = { ...state.accountPreferences };
|
||||
const acctId = state.currentAccountId;
|
||||
if (acctId) {
|
||||
accountPreferences[acctId] = {
|
||||
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
|
||||
identityKeyBindings: bindings,
|
||||
};
|
||||
}
|
||||
return { identityKeyBindings: bindings, accountPreferences };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -296,11 +337,17 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
for (const [identityId, keyId] of Object.entries(bindings)) {
|
||||
if (keyId === id) delete bindings[identityId];
|
||||
}
|
||||
const accountPreferences = { ...state.accountPreferences };
|
||||
const acctId = state.currentAccountId;
|
||||
if (acctId && accountPreferences[acctId]) {
|
||||
accountPreferences[acctId] = { ...accountPreferences[acctId], identityKeyBindings: bindings };
|
||||
}
|
||||
return {
|
||||
keyRecords: state.keyRecords.filter((k) => k.id !== id),
|
||||
unlockedKeys,
|
||||
unlockedDecryptionKeys,
|
||||
identityKeyBindings: bindings,
|
||||
accountPreferences,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -378,13 +425,32 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
},
|
||||
|
||||
setSignDefault: (identityId, value) => {
|
||||
set((state) => ({
|
||||
defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value },
|
||||
}));
|
||||
set((state) => {
|
||||
const defaultSignIdentity = { ...state.defaultSignIdentity, [identityId]: value };
|
||||
const accountPreferences = { ...state.accountPreferences };
|
||||
const acctId = state.currentAccountId;
|
||||
if (acctId) {
|
||||
accountPreferences[acctId] = {
|
||||
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
|
||||
defaultSignIdentity,
|
||||
};
|
||||
}
|
||||
return { defaultSignIdentity, accountPreferences };
|
||||
});
|
||||
},
|
||||
|
||||
setEncryptDefault: (value) => {
|
||||
set({ defaultEncrypt: value });
|
||||
set((state) => {
|
||||
const accountPreferences = { ...state.accountPreferences };
|
||||
const acctId = state.currentAccountId;
|
||||
if (acctId) {
|
||||
accountPreferences[acctId] = {
|
||||
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
|
||||
defaultEncrypt: value,
|
||||
};
|
||||
}
|
||||
return { defaultEncrypt: value, accountPreferences };
|
||||
});
|
||||
},
|
||||
|
||||
setRememberUnlockedKeys: (value) => {
|
||||
@@ -403,17 +469,41 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
|
||||
getUnlockedKey: (id) => get().unlockedKeys.get(id),
|
||||
|
||||
clearState: () => {
|
||||
clearRememberedUnlocks();
|
||||
set({
|
||||
keyRecords: [],
|
||||
publicCerts: [],
|
||||
unlockedKeys: new Map(),
|
||||
unlockedDecryptionKeys: new Map(),
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
currentAccountId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
}),
|
||||
{
|
||||
name: 'smime-preferences',
|
||||
partialize: (state): SmimePersistedState => ({
|
||||
identityKeyBindings: state.identityKeyBindings,
|
||||
defaultSignIdentity: state.defaultSignIdentity,
|
||||
defaultEncrypt: state.defaultEncrypt,
|
||||
accountPreferences: state.accountPreferences,
|
||||
rememberUnlockedKeys: state.rememberUnlockedKeys,
|
||||
autoImportSignerCerts: state.autoImportSignerCerts,
|
||||
}),
|
||||
merge: (persisted, current) => {
|
||||
const p = persisted as Partial<SmimePersistedState & { identityKeyBindings?: Record<string, string>; defaultSignIdentity?: Record<string, boolean>; defaultEncrypt?: boolean }>;
|
||||
return {
|
||||
...current,
|
||||
// Migrate legacy flat preferences into accountPreferences
|
||||
accountPreferences: p?.accountPreferences ?? {},
|
||||
rememberUnlockedKeys: p?.rememberUnlockedKeys ?? false,
|
||||
autoImportSignerCerts: p?.autoImportSignerCerts ?? true,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user