fix: preserve vault fields and align cipher compatibility

Preserve linked and unknown encrypted fields in Web edits, reject stale and concurrent saves, and support official bulk delete and restore methods while retaining legacy routes.

Add 20 offline compatibility regressions and update undici to 8.10.2. Verified 51 tests, TypeScript checks, locale validation, Web build and Worker deployment dry run. The unpatched braces advisory remains visible.
This commit is contained in:
shuaiplus committed 2026-10-05 14:25:06 +08:00
1 parent 5f71cb388c
commit 293f5ce196
26 files changed
+456 -25

No files matched your search

+9 -2
View File
@@ -833,13 +833,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
setLocalError(t('txt_item_name_is_required'));
return;
}
// Sync may change the current selection while a draft is open. Save only
// the item that this draft belongs to, and retain it if the item disappeared.
const editedCipher = isCreating ? null : cipherById.get(nextDraft.id || selectedCipherId);
if (!isCreating && !editedCipher) {
setLocalError(t('txt_item_changed_elsewhere'));
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
} else if (editedCipher) {
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
await props.onUpdate(editedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
@@ -1,4 +1,5 @@
import { useMemo } from 'preact/hooks';
import { cipherFieldMetadata } from '@/lib/cipher-fields';
import {
BookUser,
CreditCard,
@@ -558,6 +559,7 @@ export function createEmptyDraft(type: number): VaultDraft {
export function draftFromCipher(cipher: Cipher): VaultDraft {
const draft = createEmptyDraft(Number(cipher.type || 1));
draft.id = cipher.id;
draft.revisionDate = cipher.revisionDate;
draft.favorite = !!cipher.favorite;
draft.name = cipher.decName || '';
draft.folderId = cipher.folderId || '';
@@ -655,6 +657,7 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
}
draft.customFields = (cipher.fields || []).map((field) => ({
...cipherFieldMetadata(field),
type: parseFieldType(field.type),
label: field.decName || '',
value: field.decValue || '',
+7
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'preact/hooks';
import { cipherFieldMetadata } from '@/lib/cipher-fields';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import {
@@ -221,7 +222,9 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
}
next.fields = draft.customFields.map((field) => ({
...cipherFieldMetadata(field.extra || {}).extra,
type: field.type,
...(field.type === 3 ? { linkedId: field.linkedId ?? null } : {}),
name: field.label,
value: field.value,
decName: field.label,
@@ -608,6 +611,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
onNotify('success', t('txt_item_updated'));
} catch (error) {
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
if (error instanceof Error && error.message === t('txt_item_changed_elsewhere')) {
// Fetch the current item for reopening; the editor keeps the draft.
await refetchCiphers().catch(() => {});
}
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
throw error;
} finally {
+18 -4
View File
@@ -19,6 +19,8 @@ import {
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { loadVaultCoreSyncSnapshot } from './vault-sync';
import { cipherFieldMetadata } from '../cipher-fields';
import { t } from '../i18n';
type CipherLoginData = NonNullable<Cipher['login']>;
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
@@ -573,6 +575,8 @@ function plainCipherValue(decrypted: unknown, raw: unknown = ''): string {
function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
const type = Number(cipher.type || 1) || 1;
const draft: VaultDraft = {
id: cipher.id,
revisionDate: cipher.revisionDate,
type,
name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled',
notes: plainCipherValue(cipher.decNotes, cipher.notes),
@@ -650,6 +654,7 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
draft.customFields = (cipher.fields || [])
.map((field) => ({
...cipherFieldMetadata(field),
type: parseFieldType(field.type ?? 0),
label: plainCipherValue(field.decName, field.name).trim(),
value: plainCipherValue(field.decValue, field.value),
@@ -797,15 +802,17 @@ async function encryptCustomFields(
fields: VaultDraftField[],
enc: Uint8Array,
mac: Uint8Array
): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
): Promise<Array<Record<string, unknown>>> {
const out: Array<Record<string, unknown>> = [];
for (const field of fields || []) {
const label = String(field.label || '').trim();
if (!label) continue;
out.push({
...cipherFieldMetadata(field.extra || {}).extra,
type: parseFieldType(field.type),
name: await encryptTextValue(label, enc, mac),
value: await encryptTextValue(String(field.value || ''), enc, mac),
...(parseFieldType(field.type) === 3 ? { linkedId: field.linkedId ?? null } : {}),
});
}
return out;
@@ -1300,6 +1307,8 @@ async function buildCipherPayload(
if (cipher?.id) {
payload.id = cipher.id;
payload.key = keys.key;
// Use the version captured when editing began, even if sync refreshed cipher.
payload.lastKnownRevisionDate = draft.revisionDate ?? cipher.revisionDate;
}
if (type === 1) {
@@ -1327,6 +1336,7 @@ async function buildCipherPayload(
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
} else if (type === 3) {
payload.card = {
...stripDecodedObjectFields(cipher?.card),
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
@@ -1336,6 +1346,7 @@ async function buildCipherPayload(
};
} else if (type === 4) {
payload.identity = {
...stripDecodedObjectFields(cipher?.identity),
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
@@ -1426,7 +1437,7 @@ async function buildCipherPayload(
keys.mac
);
} else if (type === 2) {
payload.secureNote = { type: 0 };
payload.secureNote = { ...stripDecodedObjectFields(cipher?.secureNote), type: 0 };
}
return payload;
@@ -1462,6 +1473,9 @@ export async function updateCipher(
extraPayload?: Record<string, unknown>,
options?: { webRepair?: boolean }
): Promise<Cipher> {
if (draft.id && draft.id !== cipher.id) {
throw createApiError(t('txt_item_changed_elsewhere'), 400);
}
const payload = await buildCipherPayload(session, draft, cipher);
if (extraPayload) {
Object.assign(payload, extraPayload);
@@ -1475,7 +1489,7 @@ export async function updateCipher(
},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Update item failed');
if (!resp.ok) throw createApiError(await parseErrorMessage(resp, t('txt_update_item_failed')), resp.status);
return (await parseJson<Cipher>(resp))!;
}
+3
View File
@@ -1,4 +1,5 @@
import { hkdf } from '@/lib/crypto';
import { cipherFieldMetadata } from './cipher-fields';
import { t } from '@/lib/i18n';
import type { VaultDraft } from '@/lib/types';
import type { ImportResultSummary } from '@/components/ImportPage';
@@ -206,6 +207,8 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
return {
type: fieldType,
// Plaintext import cannot safely pass through unknown encrypted values.
...cipherFieldMetadata({ linkedId: field.linkedId }),
label,
value: asText(field.value),
};
+13
View File
@@ -0,0 +1,13 @@
import type { VaultDraftField } from './types';
// Keep metadata on each draft field so removing or reordering fields cannot
// accidentally attach another field's link or unknown encrypted properties.
export function cipherFieldMetadata(field: object): Pick<VaultDraftField, 'linkedId' | 'extra'> {
const raw = field as Record<string, unknown>;
return {
...(typeof raw.linkedId === 'number' || raw.linkedId === null ? { linkedId: raw.linkedId } : {}),
extra: Object.fromEntries(Object.entries(raw).filter(([key]) =>
!['type', 'name', 'value', 'linkedId'].includes(key) && !/^dec[A-Z]/.test(key)
)),
};
}
+1
View File
@@ -163,6 +163,7 @@ export function translateServerError(message: string | null | undefined, fallbac
const key = {
'Account is disabled': 'txt_server_error_account_disabled',
'The client copy of this cipher is out of date. Resync the client and try again.': 'txt_item_changed_elsewhere',
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
+1
View File
@@ -1087,6 +1087,7 @@ const de: Record<string, string> = {
"txt_remove_device_failed": "Fehler beim Entfernen des Geräts",
"txt_remove_all_devices_failed": "Fehler beim Entfernen aller Geräte",
"txt_update_item_failed": "Fehler beim Aktualisieren des Eintrags",
"txt_item_changed_elsewhere": "Dieser Eintrag wurde in einem anderen Client geändert. Deine Änderungen stehen noch im Formular. Kopiere sie, öffne den Eintrag erneut und speichere ihn.",
"txt_update_send_failed": "Fehler beim Aktualisieren der Sendung",
"txt_update_user_status_failed": "Fehler beim Aktualisieren des Benutzerstatus",
"txt_use_recovery_code": "Wiederherstellungscode verwenden",
+1
View File
@@ -1140,6 +1140,7 @@ const en: Record<string, string> = {
"txt_remove_device_failed": "Failed to remove device",
"txt_remove_all_devices_failed": "Failed to remove all devices",
"txt_update_item_failed": "Update item failed",
"txt_item_changed_elsewhere": "This item was changed in another client. Your edits are still in the form. Copy the changes you need, reopen the item, and save again.",
"txt_update_send_failed": "Update send failed",
"txt_update_user_status_failed": "Failed to update user status",
"txt_use_recovery_code": "Use Recovery Code",
+1
View File
@@ -1087,6 +1087,7 @@ const es: Record<string, string> = {
"txt_remove_device_failed": "Error al quitar dispositivo",
"txt_remove_all_devices_failed": "Error al quitar todos los dispositivos",
"txt_update_item_failed": "Error al actualizar elemento",
"txt_item_changed_elsewhere": "Este elemento se modificó en otro cliente. Tus cambios siguen en el formulario. Copia los cambios necesarios, vuelve a abrir el elemento y guárdalo.",
"txt_update_send_failed": "Error al actualizar envío",
"txt_update_user_status_failed": "Error al actualizar estado de usuario",
"txt_use_recovery_code": "Usar código de recuperación",
+1
View File
@@ -1087,6 +1087,7 @@ const fi: Record<string, string> = {
"txt_remove_device_failed": "Laitteen poistaminen epäonnistui",
"txt_remove_all_devices_failed": "Kaikkien laitteiden poistaminen epäonnistui",
"txt_update_item_failed": "Nimikkeen päivitys epäonnistui",
"txt_item_changed_elsewhere": "Kohdetta muutettiin toisessa sovelluksessa. Muokkauksesi ovat yhä lomakkeessa. Kopioi tarvittavat muutokset, avaa kohde uudelleen ja tallenna.",
"txt_update_send_failed": "Lähetyksen päivitys epäonnistui",
"txt_update_user_status_failed": "Käyttäjän tilan päivitys epäonnistui",
"txt_use_recovery_code": "Käytä palautuskoodia",
+1
View File
@@ -1087,6 +1087,7 @@ const fr: Record<string, string> = {
"txt_remove_device_failed": "Le retrait de l'appareil a échoué",
"txt_remove_all_devices_failed": "Le retrait de tous les appareils a échoué",
"txt_update_item_failed": "La mise à jour de l'élément a échoué",
"txt_item_changed_elsewhere": "Cet élément a été modifié dans un autre client. Vos modifications restent dans le formulaire. Copiez les changements nécessaires, rouvrez cet élément et enregistrez-le.",
"txt_update_send_failed": "La mise à jour de l'envoi a échoué",
"txt_update_user_status_failed": "La mise à jour du statut de l'utilisateur a échoué",
"txt_use_recovery_code": "Utiliser le code de récupération",
+1
View File
@@ -1087,6 +1087,7 @@ const it: Record<string, string> = {
"txt_remove_device_failed": "Impossibile rimuovere il dispositivo",
"txt_remove_all_devices_failed": "Impossibile rimuovere tutti i dispositivi",
"txt_update_item_failed": "Aggiornamento elemento fallito",
"txt_item_changed_elsewhere": "Questo elemento è stato modificato in un altro client. Le tue modifiche sono ancora nel modulo. Copia quelle necessarie, riapri questo elemento e salva.",
"txt_update_send_failed": "Aggiornamento invio fallito",
"txt_update_user_status_failed": "Aggiornamento stato utente fallito",
"txt_use_recovery_code": "Usa Codice di Recupero",
+1
View File
@@ -1087,6 +1087,7 @@ const ru: Record<string, string> = {
"txt_remove_device_failed": "Не удалось удалить устройство.",
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
"txt_update_item_failed": "Обновить элемент не удалось",
"txt_item_changed_elsewhere": "Элемент изменён в другом клиенте. Ваши правки остались в форме. Скопируйте нужные изменения, откройте элемент заново и сохраните его.",
"txt_update_send_failed": "Send обновления не удалась",
"txt_update_user_status_failed": "Не удалось обновить статус пользователя",
"txt_use_recovery_code": "Использовать код восстановления",
+1
View File
@@ -1087,6 +1087,7 @@ const sv: Record<string, string> = {
"txt_remove_device_failed": "Misslyckades med att ta bort enhet",
"txt_remove_all_devices_failed": "Misslyckades med att ta bort alla enheter",
"txt_update_item_failed": "Misslyckades med att uppdatera objekt",
"txt_item_changed_elsewhere": "Objektet ändrades i en annan klient. Dina ändringar finns kvar i formuläret. Kopiera de ändringar du behöver, öppna objektet igen och spara.",
"txt_update_send_failed": "Misslyckades med att uppdatera sändning",
"txt_update_user_status_failed": "Misslyckades med att uppdatera användarstatus",
"txt_use_recovery_code": "Använd återställningskod",
+1
View File
@@ -1090,6 +1090,7 @@ const zhCN: Record<string, string> = {
"txt_remove_device_failed": "移除设备失败",
"txt_remove_all_devices_failed": "移除所有设备失败",
"txt_update_item_failed": "更新项目失败",
"txt_item_changed_elsewhere": "条目已在其他客户端修改。你的编辑内容仍保留在表单中,请复制需要的修改,重新打开条目后再保存。",
"txt_update_send_failed": "更新 Send 失败",
"txt_update_user_status_failed": "更新用户状态失败",
"txt_use_recovery_code": "使用恢复代码",
+1
View File
@@ -1090,6 +1090,7 @@ const zhTW: Record<string, string> = {
"txt_remove_device_failed": "移除設備失敗",
"txt_remove_all_devices_failed": "移除所有設備失敗",
"txt_update_item_failed": "更新項目失敗",
"txt_item_changed_elsewhere": "項目已在其他用戶端修改。你的編輯內容仍保留在表單中,請複製需要的修改,重新開啟項目後再儲存。",
"txt_update_send_failed": "更新 Send 失敗",
"txt_update_user_status_failed": "更新用戶狀態失敗",
"txt_use_recovery_code": "使用恢復代碼",
+3
View File
@@ -320,10 +320,13 @@ export interface VaultDraftField {
type: CustomFieldType;
label: string;
value: string;
linkedId?: number | null;
extra?: Record<string, unknown>;
}
export interface VaultDraft {
id?: string;
revisionDate?: string;
type: number;
favorite: boolean;
name: string;