diff --git a/package-lock.json b/package-lock.json index f32bd7c6..439dfc71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4716,9 +4716,9 @@ } }, "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 378f0ea4..9eee0d82 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "i18n": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs", "test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts", + "test:vault-compatibility": "tsx --tsconfig webapp/tsconfig.json --import ./scripts/offline-workers-loader.mjs --test scripts/vault-compatibility.test.ts", "test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts", "test:notifications-security": "tsx --test scripts/notifications-security.test.ts", "test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs", @@ -50,7 +51,7 @@ }, "overrides": { "nanoid": "3.3.18", - "undici": "8.9.0", + "undici": "8.10.2", "@babel/core": ">=7.29.6", "esbuild": ">=0.28.1", "ws": "8.21.0", diff --git a/scripts/offline-workers-loader.mjs b/scripts/offline-workers-loader.mjs new file mode 100644 index 00000000..ae240a43 --- /dev/null +++ b/scripts/offline-workers-loader.mjs @@ -0,0 +1,15 @@ +import { registerHooks } from 'node:module'; + +// Vite injects this build constant into modules shared with Web draft helpers. +globalThis.__NODEWARDEN_DEMO__ = false; + +// Tests exercise routes and storage without running Cloudflare Durable Objects. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') { + const source = 'export class DurableObject {} export function waitUntil(p) { p.catch(() => {}); }'; + return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true }; + } + return nextResolve(specifier, context); + }, +}); diff --git a/scripts/vault-compatibility.test.ts b/scripts/vault-compatibility.test.ts new file mode 100644 index 00000000..bc2504fa --- /dev/null +++ b/scripts/vault-compatibility.test.ts @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict'; +import test, { type TestContext } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { StorageService } from '../src/services/storage'; +import { handleAuthenticatedRoute } from '../src/router-authenticated'; +import { cipherToResponse } from '../src/handlers/ciphers'; +import { bytesToBase64, encryptBw, decryptStr } from '../webapp/src/lib/crypto'; +import { decryptSingleCipher } from '../webapp/src/lib/decrypt-cipher'; +import { updateCipher, buildCipherImportPayload, repairCipherKeyMismatches } from '../webapp/src/lib/api/vault'; +import { importCipherToDraft } from '../webapp/src/lib/app-support'; +import { draftFromCipher } from '../webapp/src/components/vault/vault-page-helpers'; +import { t as translate } from '../webapp/src/lib/i18n'; + +const owner = '00000000-0000-4000-8000-000000000001'; +const otherOwner = '00000000-0000-4000-8000-000000000002'; +const id = '00000000-0000-4000-8000-000000000003'; +const otherId = '00000000-0000-4000-8000-000000000004'; +const attachmentId = '00000000-0000-4000-8000-000000000005'; +const revision = '2026-10-04T00:00:00.000Z'; +const enc = new Uint8Array(32).fill(1); +const mac = new Uint8Array(32).fill(2); +const session: any = { symEncKey: bytesToBase64(enc), symMacKey: bytesToBase64(mac) }; +const encrypt = (value: string) => encryptBw(new TextEncoder().encode(value), enc, mac); + +async function fixture(ctx: TestContext) { + // Execute production SQL in a fresh in-memory SQLite database. Only the D1 + // result wrapper and unrelated audit/notification integrations are substituted. + const sqlite = new DatabaseSync(':memory:'); + ctx.after(async () => { await new Promise((resolve) => setImmediate(resolve)); sqlite.close(); }); + sqlite.exec(` + CREATE TABLE ciphers(id TEXT PRIMARY KEY, user_id TEXT, type INTEGER, + folder_id TEXT, name TEXT, notes TEXT, favorite INTEGER, data TEXT, + reprompt INTEGER, key TEXT, created_at TEXT, updated_at TEXT, + archived_at TEXT, deleted_at TEXT); + CREATE TABLE attachments(id TEXT PRIMARY KEY, cipher_id TEXT, + file_name TEXT, size INTEGER, size_name TEXT, key TEXT); + CREATE TABLE devices(user_id TEXT, push_token TEXT); + `); + const db: any = { + prepare(sql: string) { + let values: any[] = []; + const statement = { + bind(...bound: any[]) { values = bound; return statement; }, + async run() { const result = sqlite.prepare(sql).run(...values); return { success: true, meta: { changes: Number(result.changes) } }; }, + async all() { return { success: true, results: sqlite.prepare(sql).all(...values) }; }, + async first() { return sqlite.prepare(sql).get(...values) ?? null; }, + }; + return statement; + }, + }; + ctx.mock.method(StorageService.prototype, 'updateRevisionDate', async () => revision); + ctx.mock.method(StorageService.prototype, 'createAuditLog', async () => {}); + ctx.mock.method(StorageService.prototype, 'getConfigValue', async () => null); + ctx.mock.method(StorageService.prototype, 'pruneAuditLogs', async () => {}); + const deletedBlobs: string[] = []; + const notifications: any[] = []; + const env: any = { + DB: db, + ATTACHMENTS: { delete: async (key: string) => { deletedBlobs.push(key); } }, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => name, + get: () => ({ fetch: async (_url: string, init: RequestInit) => { notifications.push(JSON.parse(init.body as string)); return new Response(null, { status: 204 }); } }), + }, + }; + const storage = new StorageService(db); + const base: any = { + id, userId: owner, type: 1, name: await encrypt('Initial title'), + notes: await encrypt('Notes'), favorite: false, folderId: null, key: null, + login: { username: await encrypt('Username'), password: await encrypt('Password') }, + createdAt: revision, updatedAt: revision, deletedAt: null, + }; + await storage.saveCipher(base); + await storage.saveCipher({ ...base, id: otherId, userId: otherOwner }); + const route = async (path: string, method: string, body?: unknown, headers: Record = {}) => { + const request = new Request(`https://vault.example.test${path}`, { + method, headers: { 'Content-Type': 'application/json', ...headers }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const response = await handleAuthenticatedRoute(request, env, owner, { id: owner } as any, path, method); + assert.ok(response, `${method} ${path} must be handled`); + return response; + }; + const attachment = { id: attachmentId, cipherId: id, fileName: await encrypt('file.txt'), key: null, size: 7, sizeName: '7 B' }; + return { storage, base, env, sqlite, route, deletedBlobs, notifications, attachment }; +} + +for (const method of ['PUT', 'POST']) { + test(`${method} bulk soft delete and restore retain ownership and return restored items`, async (ctx) => { + const f = await fixture(ctx); + await f.storage.saveAttachment(f.attachment); + const ids = [id, otherId, id, '00000000-0000-4000-8000-000000000099']; + assert.equal((await f.route('/api/ciphers/delete', method, { ids })).status, 204); + assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt); + assert.equal((await f.storage.getCipherForUser(otherId, otherOwner))?.deletedAt, null); + const response = await f.route('/api/ciphers/restore', method, { ids }); + assert.equal(response.status, 200); + const body: any = await response.json(); + assert.equal(body.object, 'list'); + assert.equal(body.continuationToken, null); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].id, id); + assert.equal(body.data[0].deletedDate, null); + assert.equal(body.data[0].attachments[0].id, attachmentId); + assert.deepEqual(f.deletedBlobs, []); + const empty: any = await (await f.route('/api/ciphers/restore', method, { ids: [] })).json(); + assert.deepEqual(empty.data, []); + }); +} + +for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete-permanent', 'POST']]) { + test(`${method} ${path} permanently deletes only owned items and their attachments`, async (ctx) => { + const f = await fixture(ctx); + await f.storage.saveAttachment(f.attachment); + await f.storage.saveAttachment({ ...f.attachment, id: otherId, cipherId: otherId }); + assert.equal((await f.route(path, method, { ids: [id, otherId, id] })).status, 204); + assert.equal(await f.storage.getCipherForUser(id, owner), null); + assert.ok(await f.storage.getCipherForUser(otherId, otherOwner)); + assert.equal(await f.storage.getAttachment(attachmentId), null); + assert.ok(await f.storage.getAttachment(otherId)); + assert.deepEqual(f.deletedBlobs, [`${id}/${attachmentId}`]); + }); +} + +test('single POST delete alias is permanent; existing root DELETE still moves an active item to trash', async (ctx) => { + const f = await fixture(ctx); + assert.equal((await f.route(`/api/ciphers/${id}`, 'DELETE')).status, 200); + assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt); + assert.equal((await f.route(`/api/ciphers/${id}/delete`, 'POST')).status, 204); + assert.equal(await f.storage.getCipherForUser(id, owner), null); + assert.equal((await f.route(`/api/ciphers/${otherId}/delete`, 'POST')).status, 404); +}); + +test('invalid bulk requests are rejected without changing vault items', async (ctx) => { + const f = await fixture(ctx); + for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete', 'PUT'], ['/api/ciphers/restore', 'PUT']]) { + assert.equal((await f.route(path, method, {})).status, 400); + assert.equal((await f.route(path, method, { ids: 'invalid' })).status, 400); + } + assert.deepEqual(await f.storage.getCipherForUser(id, owner), await f.storage.getCipher(id)); + assert.equal((await f.storage.getCipherForUser(id, owner))?.deletedAt, null); +}); + +test('stale updates less than a second apart cannot bypass checks with attachment metadata', async (ctx) => { + const f = await fixture(ctx); + await f.storage.saveAttachment(f.attachment); + await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:00.001Z' }); + const response = await f.route(`/api/ciphers/${id}`, 'PUT', { + name: await encrypt('Stale title'), lastKnownRevisionDate: revision, + attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } }, + }); + assert.equal(response.status, 400); + assert.equal((await f.storage.getCipherForUser(id, owner))?.name, f.base.name); + assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName); + assert.equal(f.notifications.length, 0); +}); + +test('a write conflict detected after reading leaves attachment metadata untouched', async (ctx) => { + const f = await fixture(ctx); + await f.storage.saveAttachment(f.attachment); + const update = StorageService.prototype.updateCipherIfUnchanged; + ctx.mock.method(StorageService.prototype, 'updateCipherIfUnchanged', async function (this: StorageService, cipher: any, expected: string) { + await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:01.000Z' }); + return update.call(this, cipher, expected); + }); + assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { + name: await encrypt('Stale title'), lastKnownRevisionDate: revision, + attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } }, + })).status, 400); + assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName); + assert.equal(f.notifications.length, 0); +}); + +test('concurrent saves from the same revision accept only one, even in the same millisecond', async (ctx) => { + const f = await fixture(ctx); + ctx.mock.method(Date, 'now', () => Date.parse(revision)); + const response = await Promise.all(['First title', 'Second title'].map(async (title) => + f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt(title), lastKnownRevisionDate: revision }) + )); + assert.deepEqual(response.map((r) => r.status).sort(), [200, 400]); + assert.equal((await f.storage.getCipherForUser(id, owner))?.updatedAt, '2026-10-04T00:00:00.001Z'); +}); + +test('a competing deletion cannot be resurrected by a delayed update', async (ctx) => { + const f = await fixture(ctx); + const get = StorageService.prototype.getCipherForUser; + ctx.mock.method(StorageService.prototype, 'getCipherForUser', async function (this: StorageService, ...args: [string, string]) { + const result = await get.apply(this, args); + await f.storage.deleteCipher(id, owner); + return result; + }); + assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Delayed title') })).status, 400); + assert.equal(await f.storage.getCipher(id), null); +}); + +test('normal legacy saves without a revision still work and nullable fields can be cleared', async (ctx) => { + const f = await fixture(ctx); + assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Legacy title'), notes: null, fields: [] })).status, 200); + const saved = await f.storage.getCipherForUser(id, owner); + assert.equal(saved?.notes, null); + assert.deepEqual(saved?.fields, []); +}); + +test('storage conditional updates cannot change another owner or recreate missing items', async (ctx) => { + const f = await fixture(ctx); + assert.equal(await f.storage.updateCipherIfUnchanged({ ...f.base, userId: otherOwner }, revision), false); + await f.storage.deleteCipher(id, owner); + assert.equal(await f.storage.updateCipherIfUnchanged(f.base, revision), false); + assert.equal(await f.storage.getCipher(id), null); +}); + +test('Web editing preserves linked IDs and field metadata through reorder, removal, encryption, storage and response', async (ctx) => { + const f = await fixture(ctx); + const extra = await encrypt('Future encrypted field'); + const cipher = { + ...f.base, + fields: [ + { type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureValue: extra }, + { type: 0, name: await encrypt('Remove me'), value: await encrypt('Remove value') }, + { type: 1, name: await encrypt('Keep me'), value: await encrypt('Secret'), futureValue: extra }, + ], + }; + await f.storage.saveCipher(cipher); + const decrypted = await decryptSingleCipher(cipherToResponse(cipher) as any, enc, mac); + const draft = draftFromCipher(decrypted); + draft.customFields = [draft.customFields[2], draft.customFields[0]]; + draft.name = 'Edited title'; + const updated = await updateCipher((path, init) => f.route(path, init!.method!, JSON.parse(init!.body as string)), session, decrypted, draft); + assert.equal(updated.fields?.length, 2); + assert.equal('lastKnownRevisionDate' in updated, false); + assert.equal(updated.fields?.[1].linkedId, 100); + for (const field of updated.fields || []) { + assert.equal((field as any).futureValue, extra); + assert.equal('decName' in field, false); + assert.equal('decValue' in field, false); + } + const roundtrip = await decryptSingleCipher(updated, enc, mac); + assert.equal(roundtrip.decName, 'Edited title'); + assert.equal(roundtrip.fields?.[0].decValue, 'Secret'); + assert.equal((await f.storage.getCipherForUser(id, owner))?.fields?.[1].linkedId, 100); +}); + +for (const [type, property] of [[3, 'card'], [4, 'identity'], [2, 'secureNote']] as const) { + test(`Web editing ${property} keeps unknown encrypted values without sending decoded properties`, async (ctx) => { + const f = await fixture(ctx); + const extra = await encrypt('Future secret'); + const source = { ...f.base, type, login: null, [property]: { futureEncrypted: extra, decFutureEncrypted: 'Decoded secret', type: 0 } }; + await f.storage.saveCipher(source); + const decrypted = await decryptSingleCipher(cipherToResponse(source) as any, enc, mac); + const draft = draftFromCipher(decrypted); + draft.name = 'Changed'; + let payload: any; + const updated = await updateCipher((path, init) => { payload = JSON.parse(init!.body as string); return f.route(path, init!.method!, payload); }, session, decrypted, draft); + assert.equal(payload[property].futureEncrypted, extra); + assert.equal('decFutureEncrypted' in payload[property], false); + assert.equal((updated as any)[property].futureEncrypted, extra); + }); +} + +test('Web sends the revision captured at edit start and exposes a localized conflict while preserving the draft', async (ctx) => { + const f = await fixture(ctx); + const initial = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac); + const draft = draftFromCipher(initial); + draft.name = 'Unsaved edit'; + const newer = { ...f.base, name: await encrypt('New remote title'), updatedAt: '2026-10-04T00:00:00.500Z' }; + await f.storage.saveCipher(newer); + const synced = await decryptSingleCipher(cipherToResponse(newer) as any, enc, mac); + let submitted: any; + await assert.rejects(updateCipher((path, init) => { + submitted = JSON.parse(init!.body as string); + return f.route(path, init!.method!, submitted); + }, session, synced, draft), (error: any) => { + assert.equal(error.status, 400); + assert.equal(error.message, translate('txt_item_changed_elsewhere')); + return true; + }); + assert.equal(submitted.lastKnownRevisionDate, revision); + assert.equal(draft.name, 'Unsaved edit'); + assert.equal((await f.storage.getCipherForUser(id, owner))?.name, newer.name); +}); + +test('plaintext import keeps linked field IDs attached to the encrypted field', async () => { + const draft = importCipherToDraft({ type: 1, name: 'Imported', fields: [{ type: 3, name: 'Linked', value: null, linkedId: 100 }] }, ''); + assert.equal(draft.customFields[0].linkedId, 100); + const payload: any = await buildCipherImportPayload(session, draft); + assert.equal(payload.fields[0].linkedId, 100); + assert.equal(await decryptStr(payload.fields[0].name, enc, mac), 'Linked'); + assert.equal('lastKnownRevisionDate' in payload, false); +}); + +test('a draft cannot be saved into a different item after the selection changes', async (ctx) => { + const f = await fixture(ctx); + const cipher = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac); + const draft = draftFromCipher(cipher); + let requested = false; + await assert.rejects(updateCipher(async () => { requested = true; return new Response(); }, session, { ...cipher, id: otherId }, draft)); + assert.equal(requested, false); +}); + +test('automatic item-key repair preserves linked metadata and the intentional revision-date behavior', async (ctx) => { + const f = await fixture(ctx); + const itemEnc = new Uint8Array(32).fill(3); + const itemMac = new Uint8Array(32).fill(4); + const itemKey = new Uint8Array([...itemEnc, ...itemMac]); + const extra = await encryptBw(new TextEncoder().encode('Future secret'), itemEnc, itemMac); + const mixed = { ...f.base, key: await encryptBw(itemKey, enc, mac), fields: [ + { type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureEncrypted: extra }, + ] }; + await f.storage.saveCipher(mixed); + const decrypted = await decryptSingleCipher(cipherToResponse(mixed) as any, enc, mac); + const repaired = await repairCipherKeyMismatches((path, init) => + f.route(path, init!.method!, JSON.parse(init!.body as string), init!.headers as Record), session, [decrypted]); + assert.equal(repaired, 1); + const saved = (await f.storage.getCipherForUser(id, owner))!; + assert.equal(saved.updatedAt, revision); + assert.equal(saved.fields?.[0].linkedId, 100); + assert.equal((saved.fields?.[0] as any).futureEncrypted, extra); + assert.equal(await decryptStr(saved.name, itemEnc, itemMac), 'Initial title'); + assert.equal(await decryptStr(saved.fields![0].name!, itemEnc, itemMac), 'Linked username'); + assert.equal('lastKnownRevisionDate' in saved, false); +}); diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 2298433a..2a8332c8 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -174,7 +174,7 @@ function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: stri const existingTs = Date.parse(existingUpdatedAt); const clientTs = Date.parse(clientRevisionDate); if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false; - return existingTs - clientTs > 1000; + return existingTs > clientTs; } function syncCipherComputedAliases(cipher: Cipher): Cipher { @@ -522,6 +522,7 @@ function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecur if (!secureNote || typeof secureNote !== 'object') return null; const type = Number(secureNote?.type ?? secureNote?.Type ?? 0); return { + ...secureNote, type: Number.isFinite(type) ? type : 0, }; } @@ -655,10 +656,6 @@ function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata return [...merged.values()]; } -function hasIncomingAttachmentMetadata(source: any): boolean { - return readIncomingAttachmentMetadata(source).length > 0; -} - async function syncIncomingAttachmentMetadata( storage: StorageService, cipherId: string, @@ -1048,7 +1045,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str const incomingPassport = readCipherProp(cipherData, ['passport', 'Passport']); const incomingPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingRevisionDate = readCipherRevisionDate(cipherData); - const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData); const preserveRevisionDate = shouldPreserveRepairableCipherUris(request) && (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true); @@ -1057,7 +1053,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400); } - if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { + if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); } @@ -1065,7 +1061,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str // Opaque passthrough: merge existing stored data with ALL incoming client fields. // Unknown/future fields from the client are preserved; server-controlled fields are protected. - const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData; + const { + preserveRevisionDate: _preserveRevisionDate, + PreserveRevisionDate: _pascalPreserveRevisionDate, + lastKnownRevisionDate: _lastKnownRevisionDate, + LastKnownRevisionDate: _pascalLastKnownRevisionDate, + ...cipherDataWithoutFlags + } = cipherData; const cipher: Cipher = { ...existingCipher, // start with all existing stored data (including unknowns) ...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields) @@ -1076,7 +1078,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str favorite: cipherData.favorite ?? existingCipher.favorite, reprompt: cipherData.reprompt ?? existingCipher.reprompt, createdAt: existingCipher.createdAt, - updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(), + updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date(Math.max(Date.now(), Date.parse(existingCipher.updatedAt) + 1)).toISOString(), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), deletedAt: existingCipher.deletedAt, }; @@ -1115,8 +1117,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str if (!folderOk) return errorResponse('Folder not found', 404); } + if (!(await storage.updateCipherIfUnchanged(cipher, existingCipher.updatedAt))) { + return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); + } + // Rejected updates must not modify attachment metadata either. await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData); - await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyCipherUpdateForRequest(request, env, cipher, revisionDate); @@ -1451,7 +1456,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId return new Response(null, { status: 204 }); } -// POST /api/ciphers/restore - Bulk restore +// PUT /api/ciphers/restore (POST retained for older NodeWarden clients) export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); @@ -1472,7 +1477,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } - return new Response(null, { status: 204 }); + return buildCipherListResponse(request, storage, userId, body.ids); } // POST /api/ciphers/delete-permanent - Bulk permanent delete diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index ff99b0c5..08ada0b6 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -290,6 +290,7 @@ export async function handleAuthenticatedRoute( if (path === '/api/ciphers' || path === '/api/ciphers/create') { if (method === 'GET') return handleGetCiphers(request, env, userId); if (method === 'POST') return handleCreateCipher(request, env, userId); + if (path === '/api/ciphers' && method === 'DELETE') return handleBulkPermanentDeleteCiphers(request, env, userId); return null; } @@ -297,7 +298,7 @@ export async function handleAuthenticatedRoute( return handleCiphersImport(request, env, userId); } - if (path === '/api/ciphers/delete' && method === 'POST') { + if (path === '/api/ciphers/delete' && (method === 'PUT' || method === 'POST')) { return handleBulkDeleteCiphers(request, env, userId); } @@ -305,7 +306,7 @@ export async function handleAuthenticatedRoute( return handleBulkPermanentDeleteCiphers(request, env, userId); } - if (path === '/api/ciphers/restore' && method === 'POST') { + if (path === '/api/ciphers/restore' && (method === 'PUT' || method === 'POST')) { return handleBulkRestoreCiphers(request, env, userId); } @@ -333,7 +334,7 @@ export async function handleAuthenticatedRoute( } if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId); - if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId); + if (subPath === '/delete' && (method === 'DELETE' || method === 'POST')) return handlePermanentDeleteCipher(request, env, userId, cipherId); if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId); if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId); if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId); diff --git a/src/services/storage-cipher-repo.ts b/src/services/storage-cipher-repo.ts index 21303cde..c6c415a5 100644 --- a/src/services/storage-cipher-repo.ts +++ b/src/services/storage-cipher-repo.ts @@ -49,6 +49,8 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([ 'updatedAt', 'updated_at', 'revisionDate', + 'lastKnownRevisionDate', + 'LastKnownRevisionDate', 'archivedAt', 'archived_at', 'archivedDate', @@ -144,6 +146,27 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip ).run(); } +// A single conditional UPDATE prevents a competing save or deletion between +// the handler's read and write from being overwritten (or resurrected). +export async function updateCipherIfUnchanged( + db: D1Database, + safeBind: SafeBind, + cipher: Cipher, + expectedUpdatedAt: string +): Promise { + const folderId = normalizeOptionalId(cipher.folderId); + const result = await safeBind( + db.prepare( + 'UPDATE ciphers SET type=?, folder_id=?, name=?, notes=?, favorite=?, data=?, reprompt=?, key=?, updated_at=?, archived_at=?, deleted_at=? ' + + 'WHERE id=? AND user_id=? AND updated_at=?' + ), + Number(cipher.type) || 1, folderId, cipher.name, cipher.notes, cipher.favorite ? 1 : 0, + buildCipherData(cipher, folderId), cipher.reprompt ?? 0, cipher.key, cipher.updatedAt, + cipher.archivedAt ?? null, cipher.deletedAt, cipher.id, cipher.userId, expectedUpdatedAt + ).run(); + return result.meta.changes === 1; +} + function sanitizeIds(ids: string[]): string[] { return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); } diff --git a/src/services/storage.ts b/src/services/storage.ts index 8fa914f7..e7691bfa 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -59,6 +59,7 @@ import { getCiphersByIds as listStoredCiphersByIds, getCiphersPage as listStoredCiphersPage, saveCipher as saveStoredCipher, + updateCipherIfUnchanged as updateStoredCipherIfUnchanged, deleteCipher as deleteStoredCipher, } from './storage-cipher-repo'; import { @@ -496,6 +497,10 @@ export class StorageService { await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); } + async updateCipherIfUnchanged(cipher: Cipher, expectedUpdatedAt: string): Promise { + return updateStoredCipherIfUnchanged(this.db, this.safeBind.bind(this), cipher, expectedUpdatedAt); + } + async deleteCipher(id: string, userId: string): Promise { await deleteStoredCipher(this.db, id, userId); } diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 58a77cb3..cf377381 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -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, }); diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 82883941..ab03ba9a 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -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 || '', diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index a3ae8dcc..c3649954 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -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 { diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index d5e2613d..ae24c7a8 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -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; 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> { - const out: Array<{ type: number; name: string | null; value: string | null }> = []; +): Promise>> { + const out: Array> = []; 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, options?: { webRepair?: boolean } ): Promise { + 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(resp))!; } diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 271c3956..7b897cef 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -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, 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), }; diff --git a/webapp/src/lib/cipher-fields.ts b/webapp/src/lib/cipher-fields.ts new file mode 100644 index 00000000..c6d170ab --- /dev/null +++ b/webapp/src/lib/cipher-fields.ts @@ -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 { + const raw = field as Record; + 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) + )), + }; +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index d5739dd7..d51b529c 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -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', diff --git a/webapp/src/lib/i18n/locales/de.ts b/webapp/src/lib/i18n/locales/de.ts index 808a6fc8..bd0f5307 100644 --- a/webapp/src/lib/i18n/locales/de.ts +++ b/webapp/src/lib/i18n/locales/de.ts @@ -1087,6 +1087,7 @@ const de: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 2c2b768e..ef8d2d4a 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -1140,6 +1140,7 @@ const en: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 0812bcc1..ae32072c 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -1087,6 +1087,7 @@ const es: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/fi.ts b/webapp/src/lib/i18n/locales/fi.ts index 6691cf43..8a69a906 100644 --- a/webapp/src/lib/i18n/locales/fi.ts +++ b/webapp/src/lib/i18n/locales/fi.ts @@ -1087,6 +1087,7 @@ const fi: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/fr.ts b/webapp/src/lib/i18n/locales/fr.ts index 7b466e81..44da0b77 100644 --- a/webapp/src/lib/i18n/locales/fr.ts +++ b/webapp/src/lib/i18n/locales/fr.ts @@ -1087,6 +1087,7 @@ const fr: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/it.ts b/webapp/src/lib/i18n/locales/it.ts index e4a7631c..0f23ef5d 100644 --- a/webapp/src/lib/i18n/locales/it.ts +++ b/webapp/src/lib/i18n/locales/it.ts @@ -1087,6 +1087,7 @@ const it: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index fc4737e4..2d7d98e2 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -1087,6 +1087,7 @@ const ru: Record = { "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": "Использовать код восстановления", diff --git a/webapp/src/lib/i18n/locales/sv.ts b/webapp/src/lib/i18n/locales/sv.ts index 07ea9f00..f360697f 100644 --- a/webapp/src/lib/i18n/locales/sv.ts +++ b/webapp/src/lib/i18n/locales/sv.ts @@ -1087,6 +1087,7 @@ const sv: Record = { "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", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 0f257c7c..183be373 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -1090,6 +1090,7 @@ const zhCN: Record = { "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": "使用恢复代码", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 0974fe51..a5854fba 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -1090,6 +1090,7 @@ const zhTW: Record = { "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": "使用恢復代碼", diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 9799b899..2dbf53aa 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -320,10 +320,13 @@ export interface VaultDraftField { type: CustomFieldType; label: string; value: string; + linkedId?: number | null; + extra?: Record; } export interface VaultDraft { id?: string; + revisionDate?: string; type: number; favorite: boolean; name: string;