diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index f2f2661..8053368 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users ( security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', status TEXT NOT NULL DEFAULT 'active', + verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, @@ -51,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers ( key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + archived_at TEXT, deleted_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); CREATE TABLE IF NOT EXISTS folders ( @@ -144,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices ( device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, + session_stamp TEXT, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (user_id, device_identifier), diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index cbaed6d..b7d5603 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -75,6 +75,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | return null; } +async function verifyUserSecret( + auth: AuthService, + user: User, + secret: string | null | undefined +): Promise { + const normalized = String(secret || '').trim(); + if (!normalized) return false; + return auth.verifyPassword(normalized, user.masterPasswordHash, user.email); +} + function toProfile(user: User, env: Env): ProfileResponse { void env; return { @@ -98,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse { forcePasswordReset: false, avatarColor: null, creationDate: user.createdAt, + verifyDevices: user.verifyDevices, role: user.role, status: user.status, object: 'profile', @@ -194,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: { + secret?: string; + masterPasswordHash?: string; + verifyDevices?: boolean; + }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (typeof body.verifyDevices !== 'boolean') { + return errorResponse('verifyDevices must be true or false', 400); + } + + const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash); + if (!verified) { + return errorResponse('User verification failed.', 400); + } + + user.verifyDevices = body.verifyDevices; + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + + return new Response(null, { status: 200 }); +} + // POST /api/accounts/keys export async function handleSetKeys(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index b7d380d..d234db2 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -26,6 +26,31 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val return { present: false, value: undefined }; } +function normalizeCipherTimestamp(value: unknown): string | null { + if (value == null || value === '') return null; + const parsed = new Date(String(value)); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toISOString(); +} + +function readCipherArchivedAt(source: any, fallback: string | null = null): string | null { + const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']); + return archived.present ? normalizeCipherTimestamp(archived.value) : fallback; +} + +function syncCipherComputedAliases(cipher: Cipher): Cipher { + cipher.archivedDate = cipher.archivedAt ?? null; + cipher.deletedDate = cipher.deletedAt ?? null; + return cipher; +} + +function normalizeCipherForStorage(cipher: Cipher): Cipher { + cipher.login = normalizeCipherLoginForStorage(cipher.login); + cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); + cipher.archivedAt = normalizeCipherTimestamp(cipher.archivedAt ?? cipher.archivedDate) ?? null; + return syncCipherComputedAliases(cipher); +} + function looksLikeCipherString(value: unknown): boolean { return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); } @@ -149,7 +174,7 @@ export function cipherToResponse( options?: { omitFido2Credentials?: boolean } ): CipherResponse { // Strip internal-only fields that must not appear in the API response - const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher; + const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); @@ -163,7 +188,7 @@ export function cipherToResponse( creationDate: createdAt, revisionDate: updatedAt, deletedDate: deletedAt, - archivedDate: null, + archivedDate: archivedAt ?? null, edit: true, viewPassword: true, permissions: { @@ -273,12 +298,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str reprompt: cipherData.reprompt || 0, createdAt: now, updatedAt: now, + archivedAt: readCipherArchivedAt(cipherData, null), deletedAt: null, }; - cipher.login = normalizeCipherLoginForStorage(cipher.login); - cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); + normalizeCipherForStorage(cipher); // Prevent referencing a folder owned by another user. if (cipher.folderId) { @@ -331,10 +356,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str reprompt: cipherData.reprompt ?? existingCipher.reprompt, createdAt: existingCipher.createdAt, updatedAt: new Date().toISOString(), + archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), deletedAt: existingCipher.deletedAt, }; - cipher.login = normalizeCipherLoginForStorage(cipher.login); - cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); // Custom fields deletion compatibility: // - Accept both camelCase "fields" and PascalCase "Fields". @@ -346,6 +370,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str } else if (request.method === 'PUT' || request.method === 'POST') { cipher.fields = null; } + normalizeCipherForStorage(cipher); // Prevent referencing a folder owned by another user. if (cipher.folderId) { @@ -376,6 +401,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str // Soft delete cipher.deletedAt = new Date().toISOString(); cipher.updatedAt = cipher.deletedAt; + syncCipherComputedAliases(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); await notifyVaultSyncForRequest(request, env, userId, revisionDate); @@ -441,6 +467,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st cipher.deletedAt = null; cipher.updatedAt = new Date().toISOString(); + syncCipherComputedAliases(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); await notifyVaultSyncForRequest(request, env, userId, revisionDate); @@ -479,6 +506,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user cipher.favorite = body.favorite; } cipher.updatedAt = new Date().toISOString(); + syncCipherComputedAliases(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); @@ -519,6 +547,131 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId: return new Response(null, { status: 204 }); } +async function buildCipherListResponse( + request: Request, + storage: StorageService, + userId: string, + ids: string[] +): Promise { + const ciphers = await storage.getCiphersByIds(ids, userId); + const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id)); + const omitFido2Credentials = shouldOmitPasskeysForResponse(request); + + return jsonResponse({ + data: ciphers.map((cipher) => + cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { + omitFido2Credentials, + }) + ), + object: 'list', + continuationToken: null, + }); +} + +function parseCipherIdList(body: { ids?: unknown }): string[] | null { + if (!Array.isArray(body.ids)) return null; + return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean))); +} + +// PUT/POST /api/ciphers/:id/archive +export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.DB); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + if (cipher.deletedAt) { + return errorResponse('Cannot archive a deleted cipher', 400); + } + + cipher.archivedAt = new Date().toISOString(); + cipher.updatedAt = cipher.archivedAt; + normalizeCipherForStorage(cipher); + await storage.saveCipher(cipher); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + const attachments = await storage.getAttachmentsByCipher(cipher.id); + return jsonResponse( + cipherToResponse(cipher, attachments, { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); +} + +// PUT/POST /api/ciphers/:id/unarchive +export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.DB); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + cipher.archivedAt = null; + cipher.updatedAt = new Date().toISOString(); + normalizeCipherForStorage(cipher); + await storage.saveCipher(cipher); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + const attachments = await storage.getAttachmentsByCipher(cipher.id); + return jsonResponse( + cipherToResponse(cipher, attachments, { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); +} + +// PUT/POST /api/ciphers/archive +export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: unknown }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const ids = parseCipherIdList(body); + if (!ids) { + return errorResponse('ids array is required', 400); + } + + const revisionDate = await storage.bulkArchiveCiphers(ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return buildCipherListResponse(request, storage, userId, ids); +} + +// PUT/POST /api/ciphers/unarchive +export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: unknown }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const ids = parseCipherIdList(body); + if (!ids) { + return errorResponse('ids array is required', 400); + } + + const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return buildCipherListResponse(request, storage, userId, ids); +} + // POST /api/ciphers/delete - Bulk soft delete export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 4400253..95ef5de 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -1,3 +1,4 @@ +import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types'; import { Env } from '../types'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; @@ -5,6 +6,101 @@ import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; import { generateUUID } from '../utils/uuid'; +function normalizeIdentifier(value: string | null | undefined): string { + return String(value || '').trim(); +} + +function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null { + if (!value?.id || !value.creationDate) return null; + return { + id: String(value.id), + creationDate: String(value.creationDate), + }; +} + +function isTrustedDevice(device: Pick): boolean { + return !!(device.encryptedUserKey && device.encryptedPublicKey); +} + +function buildDeviceResponse(device: Device): DeviceResponse { + const response = { + Id: device.deviceIdentifier, + id: device.deviceIdentifier, + UserId: device.userId, + userId: device.userId, + Name: device.name, + name: device.name, + Identifier: device.deviceIdentifier, + identifier: device.deviceIdentifier, + Type: device.type, + type: device.type, + CreationDate: device.createdAt, + creationDate: device.createdAt, + RevisionDate: device.updatedAt, + revisionDate: device.updatedAt, + IsTrusted: isTrustedDevice(device), + isTrusted: isTrustedDevice(device), + EncryptedUserKey: device.encryptedUserKey, + encryptedUserKey: device.encryptedUserKey, + EncryptedPublicKey: device.encryptedPublicKey, + encryptedPublicKey: device.encryptedPublicKey, + DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest), + devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest), + object: 'device', + }; + return response as DeviceResponse; +} + +function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse { + const response = { + Id: device.deviceIdentifier, + id: device.deviceIdentifier, + Name: device.name, + name: device.name, + Identifier: device.deviceIdentifier, + identifier: device.deviceIdentifier, + Type: device.type, + type: device.type, + CreationDate: device.createdAt, + creationDate: device.createdAt, + EncryptedUserKey: device.encryptedUserKey, + encryptedUserKey: device.encryptedUserKey, + EncryptedPublicKey: device.encryptedPublicKey, + encryptedPublicKey: device.encryptedPublicKey, + object: 'protectedDevice', + }; + return response as ProtectedDeviceWireResponse; +} + +function parseKeysBody(body: any, fallback?: Device): { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; +} { + return { + encryptedUserKey: + Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey') + ? body?.encryptedUserKey ?? null + : fallback?.encryptedUserKey ?? null, + encryptedPublicKey: + Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey') + ? body?.encryptedPublicKey ?? null + : fallback?.encryptedPublicKey ?? null, + encryptedPrivateKey: + Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey') + ? body?.encryptedPrivateKey ?? null + : fallback?.encryptedPrivateKey ?? null, + }; +} + +async function readJsonBody(request: Request): Promise { + try { + return await request.json(); + } catch { + return null; + } +} + // GET /api/devices/knowndevice // Compatible with Bitwarden/Vaultwarden behavior: // - X-Request-Email: base64url(email) without padding @@ -28,20 +124,42 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin const devices = await storage.getDevicesByUserId(userId); return jsonResponse({ - data: devices.map(device => ({ - id: device.deviceIdentifier, - name: device.name, - identifier: device.deviceIdentifier, - type: device.type, - creationDate: device.createdAt, - revisionDate: device.updatedAt, - object: 'device', - })), + data: devices.map((device) => buildDeviceResponse(device)), object: 'list', continuationToken: null, }); } +// GET /api/devices/identifier/:deviceIdentifier +export async function handleGetDeviceByIdentifier( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + void request; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); + if (!device) { + return errorResponse('Device not found', 404); + } + + return jsonResponse(buildDeviceResponse(device)); +} + +// GET /api/devices/:deviceIdentifier +export async function handleGetDevice( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier); +} + // GET /api/devices/authorized // Returns known devices together with active 2FA remember-token expiry. export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise { @@ -64,12 +182,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use knownIdentifiers.add(device.deviceIdentifier); const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier); return { - id: device.deviceIdentifier, - name: device.name, - identifier: device.deviceIdentifier, - type: device.type, - creationDate: device.createdAt, - revisionDate: device.updatedAt, + ...buildDeviceResponse(device), online: onlineSet.has(device.deviceIdentifier), trusted: !!trustedInfo, trustedTokenCount: trustedInfo?.tokenCount || 0, @@ -80,13 +193,22 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use for (const row of trusted) { if (knownIdentifiers.has(row.deviceIdentifier)) continue; - data.push({ - id: row.deviceIdentifier, + const placeholderDevice: Device = { + userId, + deviceIdentifier: row.deviceIdentifier, name: 'Unknown device', - identifier: row.deviceIdentifier, type: 14, - creationDate: '', - revisionDate: '', + sessionStamp: '', + encryptedUserKey: null, + encryptedPublicKey: null, + encryptedPrivateKey: null, + devicePendingAuthRequest: null, + createdAt: '', + updatedAt: '', + }; + data.push({ + ...buildDeviceResponse(placeholderDevice), + isTrusted: true, online: onlineSet.has(row.deviceIdentifier), trusted: true, trustedTokenCount: row.tokenCount, @@ -166,6 +288,138 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId: return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); } +// PUT/POST /api/devices/identifier/:deviceIdentifier/keys +export async function handleUpdateDeviceKeys( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const body = await readJsonBody(request); + const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); + if (!device) { + return errorResponse('Device not found', 404); + } + + const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device)); + if (!updated) { + return errorResponse('Device not found', 404); + } + + const nextDevice = await storage.getDevice(userId, normalized); + return jsonResponse(buildDeviceResponse(nextDevice || device)); +} + +// POST /api/devices/update-trust +export async function handleUpdateDeviceTrust( + request: Request, + env: Env, + userId: string +): Promise { + const body = await readJsonBody(request); + const storage = new StorageService(env.DB); + const currentDeviceIdentifier = + normalizeIdentifier(request.headers.get('Device-Identifier')) || + normalizeIdentifier(request.headers.get('X-Device-Identifier')); + + const updates: Array<{ + deviceIdentifier: string; + keys: { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; + }; + }> = []; + + if (currentDeviceIdentifier && body?.currentDevice) { + updates.push({ + deviceIdentifier: currentDeviceIdentifier, + keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined), + }); + } + + if (Array.isArray(body?.otherDevices)) { + for (const item of body.otherDevices) { + const deviceIdentifier = normalizeIdentifier(item?.deviceId); + if (!deviceIdentifier) continue; + updates.push({ + deviceIdentifier, + keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined), + }); + } + } + + let updatedCount = 0; + for (const update of updates) { + const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys); + if (ok) updatedCount++; + } + + return jsonResponse({ success: true, updated: updatedCount }); +} + +// POST /api/devices/untrust +export async function handleUntrustDevices( + request: Request, + env: Env, + userId: string +): Promise { + const body = await readJsonBody(request); + const storage = new StorageService(env.DB); + const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : []; + const removed = await storage.clearDeviceKeys(userId, devices); + for (const deviceIdentifier of devices) { + if (!deviceIdentifier) continue; + await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier); + } + return jsonResponse({ success: true, removed }); +} + +// POST /api/devices/:deviceIdentifier/retrieve-keys +export async function handleRetrieveDeviceKeys( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + void request; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); + if (!device) { + return errorResponse('Device not found', 404); + } + + return jsonResponse(buildProtectedDeviceResponse(device)); +} + +// POST /api/devices/:id/deactivate +export async function handleDeactivateDevice( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + void request; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const storage = new StorageService(env.DB); + await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); + await storage.deleteRefreshTokensByDevice(userId, normalized); + const deleted = await storage.deleteDevice(userId, normalized); + if (deleted) { + await notifyUserLogout(env, userId, normalized); + } + return jsonResponse({ success: deleted }); +} + // PUT /api/devices/identifier/{deviceIdentifier}/token // Bitwarden mobile reports push token updates to this endpoint. // NodeWarden does not implement push notifications, so accept and no-op. @@ -182,3 +436,31 @@ export async function handleUpdateDeviceToken( return new Response(null, { status: 200 }); } +// PUT/POST /api/devices/:deviceIdentifier/web-push-auth +export async function handleUpdateDeviceWebPushAuth( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + void request; + void env; + void userId; + void deviceIdentifier; + return new Response(null, { status: 200 }); +} + +// PUT/POST /api/devices/:deviceIdentifier/clear-token +export async function handleClearDeviceToken( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + void request; + void env; + void userId; + void deviceIdentifier; + return new Response(null, { status: 200 }); +} + diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index d450366..34371b9 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -31,6 +31,28 @@ function resolveTotpSecret(userSecret: string | null): string | null { return null; } +function buildPreloginResponse( + email: string, + kdfType: number, + kdfIterations: number, + kdfMemory: number | null, + kdfParallelism: number | null +): Record { + return { + kdf: kdfType, + kdfIterations, + kdfMemory, + kdfParallelism, + KdfSettings: { + KdfType: kdfType, + Iterations: kdfIterations, + Memory: kdfMemory, + Parallelism: kdfParallelism, + }, + Salt: email.toLowerCase(), + }; +} + function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response { const providers = includeRecoveryCode ? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE] @@ -426,12 +448,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise { @@ -60,10 +62,10 @@ export async function getCipher(db: D1Database, id: string): Promise { const data = JSON.stringify(cipher); const stmt = db.prepare( - 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + - 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' + 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at' ); await safeBind( stmt, @@ -79,10 +81,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip cipher.key, cipher.createdAt, cipher.updatedAt, + cipher.archivedAt ?? null, cipher.deletedAt ).run(); } +function sanitizeIds(ids: string[]): string[] { + return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); +} + export async function deleteCipher(db: D1Database, id: string, userId: string): Promise { await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); } @@ -95,7 +102,7 @@ export async function bulkSoftDeleteCiphers( userId: string ): Promise { if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + const uniqueIds = sanitizeIds(ids); if (!uniqueIds.length) return null; const now = new Date().toISOString(); @@ -126,7 +133,7 @@ export async function bulkRestoreCiphers( userId: string ): Promise { if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + const uniqueIds = sanitizeIds(ids); if (!uniqueIds.length) return null; const now = new Date().toISOString(); @@ -157,7 +164,7 @@ export async function bulkDeleteCiphers( userId: string ): Promise { if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + const uniqueIds = sanitizeIds(ids); if (!uniqueIds.length) return null; const chunkSize = sqlChunkSize(1); @@ -212,7 +219,7 @@ export async function getCiphersByIds( userId: string ): Promise { if (ids.length === 0) return []; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + const uniqueIds = sanitizeIds(ids); if (!uniqueIds.length) return []; const chunkSize = sqlChunkSize(1); @@ -242,7 +249,7 @@ export async function bulkMoveCiphers( ): Promise { if (ids.length === 0) return null; const now = new Date().toISOString(); - const uniqueIds = Array.from(new Set(ids)); + const uniqueIds = sanitizeIds(ids); const patch = JSON.stringify({ folderId, updatedAt: now }); const chunkSize = sqlChunkSize(4); @@ -261,3 +268,65 @@ export async function bulkMoveCiphers( return updateRevisionDate(userId); } + +export async function bulkArchiveCiphers( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return null; + const uniqueIds = sanitizeIds(ids); + if (!uniqueIds.length) return null; + + const now = new Date().toISOString(); + const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now }); + const chunkSize = sqlChunkSize(4); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db + .prepare( + `UPDATE ciphers + SET archived_at = ?, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL` + ) + .bind(now, now, patch, userId, ...chunk) + .run(); + } + + return updateRevisionDate(userId); +} + +export async function bulkUnarchiveCiphers( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return null; + const uniqueIds = sanitizeIds(ids); + if (!uniqueIds.length) return null; + + const now = new Date().toISOString(); + const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now }); + const chunkSize = sqlChunkSize(3); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db + .prepare( + `UPDATE ciphers + SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(now, patch, userId, ...chunk) + .run(); + } + + return updateRevisionDate(userId); +} diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index b9bbf8d..522a055 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -10,6 +10,9 @@ function mapDeviceRow(row: any): Device { name: row.name, type: row.type, sessionStamp: row.session_stamp || '', + encryptedUserKey: row.encrypted_user_key ?? null, + encryptedPublicKey: row.encrypted_public_key ?? null, + encryptedPrivateKey: row.encrypted_private_key ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -22,19 +25,92 @@ export async function upsertDevice( deviceIdentifier: string, name: string, type: number, - sessionStamp?: string + sessionStamp?: string, + keys?: { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; + } ): Promise { const now = new Date().toISOString(); const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; await db .prepare( - 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + - 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + + 'updated_at=excluded.updated_at' + ) + .bind( + userId, + deviceIdentifier, + name, + type, + effectiveSessionStamp, + keys?.encryptedUserKey ?? null, + keys?.encryptedPublicKey ?? null, + keys?.encryptedPrivateKey ?? null, + now, + now ) - .bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now) .run(); } +export async function updateDeviceKeys( + db: D1Database, + userId: string, + deviceIdentifier: string, + keys: { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; + } +): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare( + 'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' + + 'WHERE user_id = ? AND device_identifier = ?' + ) + .bind( + keys.encryptedUserKey ?? null, + keys.encryptedPublicKey ?? null, + keys.encryptedPrivateKey ?? null, + now, + userId, + deviceIdentifier + ) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + +export async function clearDeviceKeys( + db: D1Database, + userId: string, + deviceIdentifiers: string[] +): Promise { + const uniqueIds = Array.from( + new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean)) + ); + if (!uniqueIds.length) return 0; + + const placeholders = uniqueIds.map(() => '?').join(','); + const result = await db + .prepare( + `UPDATE devices + SET encrypted_user_key = NULL, + encrypted_public_key = NULL, + encrypted_private_key = NULL, + updated_at = ? + WHERE user_id = ? AND device_identifier IN (${placeholders})` + ) + .bind(new Date().toISOString(), userId, ...uniqueIds) + .run(); + return Number(result.meta.changes ?? 0); +} + export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const row = await db .prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') @@ -57,7 +133,7 @@ export async function isKnownDeviceByEmail( export async function getDevicesByUserId(db: D1Database, userId: string): Promise { const res = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' ) .bind(userId) @@ -68,7 +144,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const row = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' ) .bind(userId, deviceIdentifier) diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index f73dd84..7b1d947 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -6,10 +6,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + - 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', + 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', + 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', @@ -20,9 +21,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE TABLE IF NOT EXISTS ciphers (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' + 'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' + - 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' + + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'ALTER TABLE ciphers ADD COLUMN archived_at TEXT', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', 'CREATE TABLE IF NOT EXISTS folders (' + @@ -68,12 +71,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE TABLE IF NOT EXISTS devices (' + - 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'PRIMARY KEY (user_id, device_identifier), ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)', 'ALTER TABLE devices ADD COLUMN session_stamp TEXT', + 'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT', + 'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT', + 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned_at TEXT', diff --git a/src/services/storage-user-repo.ts b/src/services/storage-user-repo.ts index a01ff89..54faff2 100644 --- a/src/services/storage-user-repo.ts +++ b/src/services/storage-user-repo.ts @@ -1,6 +1,10 @@ import type { User } from '../types'; type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; +const USER_SELECT_COLUMNS = + 'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + + 'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' + + 'totp_secret, totp_recovery_code, created_at, updated_at'; function mapUserRow(row: any): User { return { @@ -19,6 +23,7 @@ function mapUserRow(row: any): User { securityStamp: row.security_stamp, role: row.role === 'admin' ? 'admin' : 'user', status: row.status === 'banned' ? 'banned' : 'active', + verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, totpSecret: row.totp_secret ?? null, totpRecoveryCode: row.totp_recovery_code ?? null, createdAt: row.created_at, @@ -28,9 +33,7 @@ function mapUserRow(row: any): User { export async function getUser(db: D1Database, email: string): Promise { const row = await db - .prepare( - 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?' - ) + .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`) .bind(email.toLowerCase()) .first(); if (!row) return null; @@ -39,9 +42,7 @@ export async function getUser(db: D1Database, email: string): Promise { const row = await db - .prepare( - 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?' - ) + .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`) .bind(id) .first(); if (!row) return null; @@ -55,9 +56,7 @@ export async function getUserCount(db: D1Database): Promise { export async function getAllUsers(db: D1Database): Promise { const res = await db - .prepare( - 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC' - ) + .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`) .all(); return (res.results || []).map((row) => mapUserRow(row)); } @@ -65,11 +64,11 @@ export async function getAllUsers(db: D1Database): Promise { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' ); await safeBind( stmt, @@ -88,6 +87,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): user.securityStamp, user.role, user.status, + user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, user.createdAt, @@ -102,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User) export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await safeBind( @@ -123,6 +123,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: user.securityStamp, user.role, user.status, + user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, user.createdAt, diff --git a/src/services/storage.ts b/src/services/storage.ts index 607ce52..b9f0645 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -36,10 +36,12 @@ import { saveFolder as saveStoredFolder, } from './storage-folder-repo'; import { + bulkArchiveCiphers as archiveStoredCiphers, bulkDeleteCiphers as deleteStoredCiphers, bulkMoveCiphers as moveStoredCiphers, bulkRestoreCiphers as restoreStoredCiphers, bulkSoftDeleteCiphers as softDeleteStoredCiphers, + bulkUnarchiveCiphers as unarchiveStoredCiphers, getAllCiphers as listStoredCiphers, getCipher as findStoredCipher, getCiphersByIds as listStoredCiphersByIds, @@ -80,6 +82,7 @@ import { import { deleteDevice as deleteStoredDevice, deleteDevicesByUserId as deleteStoredDevicesByUserId, + clearDeviceKeys as clearStoredDeviceKeys, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, getDevice as findStoredDevice, @@ -90,6 +93,7 @@ import { isKnownDeviceByEmail as getKnownStoredDeviceByEmail, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, upsertDevice as saveStoredDevice, + updateDeviceKeys as updateStoredDeviceKeys, } from './storage-device-repo'; import { ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, @@ -102,7 +106,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-03-19.1'; +const STORAGE_SCHEMA_VERSION = '2026-03-23.1'; // D1-backed storage. // Contract: @@ -286,6 +290,14 @@ export class StorageService { return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } + async bulkArchiveCiphers(ids: string[], userId: string): Promise { + return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); + } + + async bulkUnarchiveCiphers(ids: string[], userId: string): Promise { + return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); + } + async bulkDeleteCiphers(ids: string[], userId: string): Promise { return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } @@ -495,8 +507,19 @@ export class StorageService { // --- Devices --- - async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise { - await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp); + async upsertDevice( + userId: string, + deviceIdentifier: string, + name: string, + type: number, + sessionStamp?: string, + keys?: { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; + } + ): Promise { + await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys); } async isKnownDevice(userId: string, deviceIdentifier: string): Promise { @@ -515,6 +538,22 @@ export class StorageService { return findStoredDevice(this.db, userId, deviceIdentifier); } + async updateDeviceKeys( + userId: string, + deviceIdentifier: string, + keys: { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; + } + ): Promise { + return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); + } + + async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise { + return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); + } + async deleteDevice(userId: string, deviceIdentifier: string): Promise { return deleteStoredDevice(this.db, userId, deviceIdentifier); } diff --git a/src/types/index.ts b/src/types/index.ts index cb3ea63..b00b032 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,6 +47,7 @@ export interface User { securityStamp: string; role: UserRole; status: UserStatus; + verifyDevices?: boolean; totpSecret: string | null; totpRecoveryCode: string | null; createdAt: string; @@ -169,6 +170,7 @@ export interface Cipher { key: string | null; createdAt: string; updatedAt: string; + archivedAt: string | null; deletedAt: string | null; /** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */ [key: string]: any; @@ -189,10 +191,47 @@ export interface Device { name: string; type: number; sessionStamp: string; + encryptedUserKey: string | null; + encryptedPublicKey: string | null; + encryptedPrivateKey: string | null; + devicePendingAuthRequest?: DevicePendingAuthRequest | null; createdAt: string; updatedAt: string; } +export interface DevicePendingAuthRequest { + id: string; + creationDate: string; +} + +export interface DeviceResponse { + id: string; + userId?: string | null; + name: string; + identifier: string; + type: number; + creationDate: string; + revisionDate: string; + isTrusted: boolean; + encryptedUserKey: string | null; + encryptedPublicKey: string | null; + devicePendingAuthRequest: DevicePendingAuthRequest | null; + object: string; + [key: string]: any; +} + +export interface ProtectedDeviceResponse { + id: string; + name: string; + identifier: string; + type: number; + creationDate: string; + encryptedUserKey: string | null; + encryptedPublicKey: string | null; + object: string; + [key: string]: any; +} + export interface RefreshTokenRecord { userId: string; expiresAt: number; @@ -351,6 +390,7 @@ export interface ProfileResponse { forcePasswordReset: boolean; avatarColor: string | null; creationDate: string; + verifyDevices?: boolean; role?: UserRole; status?: UserStatus; object: string; @@ -409,6 +449,13 @@ export interface SyncResponse { domains: any; policies: any[]; sends: SendResponse[]; + UserDecryption?: { + MasterPasswordUnlock: MasterPasswordUnlock | null; + TrustedDeviceOption?: null; + KeyConnectorOption?: null; + WebAuthnPrfOption?: null; + Object?: string; + } | null; // PascalCase for desktop/browser clients UserDecryptionOptions: UserDecryptionOptions | null; // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 8d3c407..644555c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -974,9 +974,13 @@ export default function App() { onCreateVaultItem: vaultSendActions.createVaultItem, onUpdateVaultItem: vaultSendActions.updateVaultItem, onDeleteVaultItem: vaultSendActions.deleteVaultItem, + onArchiveVaultItem: vaultSendActions.archiveVaultItem, + onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem, onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, + onBulkArchiveVaultItems: vaultSendActions.bulkArchiveVaultItems, + onBulkUnarchiveVaultItems: vaultSendActions.bulkUnarchiveVaultItems, onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems, onVerifyMasterPassword: vaultSendActions.verifyMasterPassword, onCreateFolder: vaultSendActions.createFolder, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index b071ff7..303d578 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -64,9 +64,13 @@ export interface AppMainRoutesProps { onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise; onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; onDeleteVaultItem: (cipher: Cipher) => Promise; + onArchiveVaultItem: (cipher: Cipher) => Promise; + onUnarchiveVaultItem: (cipher: Cipher) => Promise; onBulkDeleteVaultItems: (ids: string[]) => Promise; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise; onBulkRestoreVaultItems: (ids: string[]) => Promise; + onBulkArchiveVaultItems: (ids: string[]) => Promise; + onBulkUnarchiveVaultItems: (ids: string[]) => Promise; onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; onCreateFolder: (name: string) => Promise; @@ -174,9 +178,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onCreate={props.onCreateVaultItem} onUpdate={props.onUpdateVaultItem} onDelete={props.onDeleteVaultItem} + onArchive={props.onArchiveVaultItem} + onUnarchive={props.onUnarchiveVaultItem} onBulkDelete={props.onBulkDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkRestore={props.onBulkRestoreVaultItems} + onBulkArchive={props.onBulkArchiveVaultItems} + onBulkUnarchive={props.onBulkUnarchiveVaultItems} onBulkMove={props.onBulkMoveVaultItems} onVerifyMasterPassword={props.onVerifyMasterPassword} onNotify={props.onNotify} diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index fc10610..c7bd0b9 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -4,7 +4,7 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard'; import { calcTotpNow } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; -import { websiteIconUrl } from '@/components/vault/vault-page-helpers'; +import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers'; interface TotpCodesPageProps { ciphers: Cipher[]; @@ -82,8 +82,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { () => props.ciphers .filter((cipher) => { - const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); - return !isDeleted && !!cipher.login?.decTotp; + return isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp; }) .sort((a, b) => { const nameA = (a.decName || a.name || '').trim().toLowerCase(); diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 8445d75..e5dc5a6 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -17,6 +17,9 @@ import { buildCipherDuplicateSignature, firstCipherUri, firstPasskeyCreationTime, + isCipherVisibleInArchive, + isCipherVisibleInNormalVault, + isCipherVisibleInTrash, sortTimeValue, type SidebarFilter, type VaultSortMode, @@ -36,9 +39,13 @@ interface VaultPageProps { onCreate: (draft: VaultDraft, attachments?: File[]) => Promise; onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; onDelete: (cipher: Cipher) => Promise; + onArchive: (cipher: Cipher) => Promise; + onUnarchive: (cipher: Cipher) => Promise; onBulkDelete: (ids: string[]) => Promise; onBulkPermanentDelete: (ids: string[]) => Promise; onBulkRestore: (ids: string[]) => Promise; + onBulkArchive: (ids: string[]) => Promise; + onBulkUnarchive: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; @@ -229,8 +236,7 @@ export default function VaultPage(props: VaultPageProps) { const duplicateSignatureCounts = useMemo(() => { const counts = new Map(); for (const cipher of props.ciphers) { - const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); - if (isDeleted) continue; + if (!isCipherVisibleInNormalVault(cipher)) continue; const signature = buildCipherDuplicateSignature(cipher); counts.set(signature, (counts.get(signature) || 0) + 1); } @@ -239,11 +245,12 @@ export default function VaultPage(props: VaultPageProps) { const filteredCiphers = useMemo(() => { const next = props.ciphers.filter((cipher) => { - const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt); if (sidebarFilter.kind === 'trash') { - if (!isDeleted) return false; + if (!isCipherVisibleInTrash(cipher)) return false; + } else if (sidebarFilter.kind === 'archive') { + if (!isCipherVisibleInArchive(cipher)) return false; } else { - if (isDeleted) return false; + if (!isCipherVisibleInNormalVault(cipher)) return false; if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { return false; } @@ -677,6 +684,34 @@ function folderName(id: string | null | undefined): string { } } + async function confirmBulkArchive(): Promise { + const ids = Object.entries(selectedMap) + .filter(([, selected]) => selected) + .map(([id]) => id); + if (!ids.length) return; + setBusy(true); + try { + await props.onBulkArchive(ids); + setSelectedMap({}); + } finally { + setBusy(false); + } + } + + async function confirmBulkUnarchive(): Promise { + const ids = Object.entries(selectedMap) + .filter(([, selected]) => selected) + .map(([id]) => id); + if (!ids.length) return; + setBusy(true); + try { + await props.onBulkUnarchive(ids); + setSelectedMap({}); + } finally { + setBusy(false); + } + } + async function confirmDeleteAllFolders(): Promise { if (!props.folders.length) return; setBusy(true); @@ -760,6 +795,8 @@ function folderName(id: string | null | undefined): string { onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)} onStartCreate={startCreate} onBulkRestore={() => void confirmBulkRestore()} + onBulkArchive={() => void confirmBulkArchive()} + onBulkUnarchive={() => void confirmBulkUnarchive()} onOpenMove={() => { setMoveFolderId('__none__'); setMoveOpen(true); @@ -851,6 +888,8 @@ function folderName(id: string | null | undefined): string { attachmentDownloadPercent={props.attachmentDownloadPercent} onStartEdit={startEdit} onDelete={setPendingDelete} + onArchive={props.onArchive} + onUnarchive={props.onUnarchive} /> )} diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index b8774b2..7723249 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -1,5 +1,5 @@ import { useState } from 'preact/hooks'; -import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact'; +import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact'; import type { Cipher } from '@/lib/types'; import { t } from '@/lib/i18n'; import { @@ -31,11 +31,14 @@ interface VaultDetailViewProps { onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onStartEdit: () => void; onDelete: (cipher: Cipher) => void; + onArchive: (cipher: Cipher) => Promise; + onUnarchive: (cipher: Cipher) => Promise; } export default function VaultDetailView(props: VaultDetailViewProps) { const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); + const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const formatDownloadLabel = (attachmentId: string) => { const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); @@ -62,6 +65,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {

{props.selectedCipher.decName || t('txt_no_name')}

{props.folderName(props.selectedCipher.folderId)}
+ {isArchived &&
{t('txt_archived')}
}
{props.selectedCipher.login && ( @@ -351,6 +355,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) { + {isArchived ? ( + + ) : ( + + )} )} - {props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && ( + {props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && ( + + )} + {props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && ( + + )} + {props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && ( diff --git a/webapp/src/components/vault/VaultSidebar.tsx b/webapp/src/components/vault/VaultSidebar.tsx index 06ddfad..ea76c45 100644 --- a/webapp/src/components/vault/VaultSidebar.tsx +++ b/webapp/src/components/vault/VaultSidebar.tsx @@ -1,4 +1,5 @@ import { + Archive, Copy, CreditCard, Folder as FolderIcon, @@ -48,6 +49,9 @@ export default function VaultSidebar(props: VaultSidebarProps) { + diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index c15d278..3399ef4 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -16,6 +16,7 @@ export type VaultSortMode = 'edited' | 'created' | 'name'; export type SidebarFilter = | { kind: 'all' } | { kind: 'favorite' } + | { kind: 'archive' } | { kind: 'trash' } | { kind: 'duplicates' } | { kind: 'type'; value: TypeFilter } @@ -71,6 +72,34 @@ export function cipherTypeKey(type: number): TypeFilter { return 'ssh'; } +function cipherDeletedValue(cipher: Cipher): boolean { + return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); +} + +function cipherArchivedValue(cipher: Cipher): boolean { + return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt); +} + +export function isCipherDeleted(cipher: Cipher): boolean { + return cipherDeletedValue(cipher); +} + +export function isCipherArchived(cipher: Cipher): boolean { + return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher); +} + +export function isCipherVisibleInNormalVault(cipher: Cipher): boolean { + return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher); +} + +export function isCipherVisibleInArchive(cipher: Cipher): boolean { + return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher); +} + +export function isCipherVisibleInTrash(cipher: Cipher): boolean { + return cipherDeletedValue(cipher); +} + export function cipherTypeLabel(type: number): string { if (type === 1) return t('txt_login'); if (type === 3) return t('txt_card'); diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 76e8dc9..8adfa2c 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -22,7 +22,9 @@ import { } from '@/lib/app-support'; import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send'; import { + archiveCipher, buildCipherImportPayload, + bulkArchiveCiphers, bulkDeleteCiphers, bulkDeleteFolders, bulkMoveCiphers, @@ -40,6 +42,7 @@ import { type CiphersImportPayload, type ImportedCipherMapEntry, updateCipher, + unarchiveCipher, uploadCipherAttachment, } from '@/lib/api/vault'; import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth'; @@ -237,6 +240,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } }, + async archiveVaultItem(cipher: Cipher) { + try { + await archiveCipher(authedFetch, cipher.id); + await Promise.all([refetchCiphers(), refetchFolders()]); + onNotify('success', t('txt_item_archived')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); + throw error; + } + }, + + async unarchiveVaultItem(cipher: Cipher) { + try { + await unarchiveCipher(authedFetch, cipher.id); + await Promise.all([refetchCiphers(), refetchFolders()]); + onNotify('success', t('txt_item_unarchived')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); + throw error; + } + }, + async bulkDeleteVaultItems(ids: string[]) { try { await bulkDeleteCiphers(authedFetch, ids); @@ -248,6 +273,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } }, + async bulkArchiveVaultItems(ids: string[]) { + try { + await bulkArchiveCiphers(authedFetch, ids); + await Promise.all([refetchCiphers(), refetchFolders()]); + onNotify('success', t('txt_archived_selected_items')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed')); + throw error; + } + }, + + async bulkUnarchiveVaultItems(ids: string[]) { + try { + await bulkUnarchiveCiphers(authedFetch, ids); + await Promise.all([refetchCiphers(), refetchFolders()]); + onNotify('success', t('txt_unarchived_selected_items')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed')); + throw error; + } + }, + async bulkMoveVaultItems(ids: string[], folderId: string | null) { try { await bulkMoveCiphers(authedFetch, ids, folderId); diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 2630354..856c8f1 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -582,6 +582,20 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): if (!resp.ok) throw new Error('Delete item failed'); } +export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id) throw new Error('Cipher id is required'); + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' }); + if (!resp.ok) throw new Error('Archive item failed'); +} + +export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id) throw new Error('Cipher id is required'); + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' }); + if (!resp.ok) throw new Error('Unarchive item failed'); +} + export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { @@ -594,6 +608,18 @@ export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]) } } +export async function bulkArchiveCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { + const resp = await authedFetch('/api/ciphers/archive', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: chunk }), + }); + if (!resp.ok) throw new Error('Bulk archive failed'); + } +} + export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { @@ -618,6 +644,18 @@ export async function bulkRestoreCiphers(authedFetch: AuthedFetch, ids: string[] } } +export async function bulkUnarchiveCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { + const resp = await authedFetch('/api/ciphers/unarchive', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: chunk }), + }); + if (!resp.ok) throw new Error('Bulk unarchive failed'); + } +} + export async function bulkMoveCiphers( authedFetch: AuthedFetch, ids: string[], diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 3132d26..ea77a95 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -280,6 +280,18 @@ const messages: Record> = { txt_delete_item: "Delete Item", txt_delete_item_failed: "Delete item failed", txt_delete_permanently: "Delete Permanently", + txt_archive: "Archive", + txt_archived: "Archived", + txt_archive_selected: "Archive", + txt_item_archived: "Item archived", + txt_item_unarchived: "Item unarchived", + txt_archived_selected_items: "Archived selected items", + txt_unarchived_selected_items: "Unarchived selected items", + txt_archive_item_failed: "Archive item failed", + txt_unarchive_item_failed: "Unarchive item failed", + txt_bulk_archive_failed: "Bulk archive failed", + txt_bulk_unarchive_failed: "Bulk unarchive failed", + txt_unarchive: "Unarchive", txt_delete_selected: "Delete Selected", txt_delete_selected_items: "Delete Selected Items", txt_delete_selected_items_permanently: "Delete Selected Items Permanently", @@ -1363,6 +1375,18 @@ zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密, zhCNOverrides.txt_import_export_title = '导入导出'; zhCNOverrides.txt_new_type_header = '新建{type}'; zhCNOverrides.txt_edit_type_header = '编辑{type}'; +zhCNOverrides.txt_archive = '归档'; +zhCNOverrides.txt_archived = '已归档'; +zhCNOverrides.txt_archive_selected = '归档'; +zhCNOverrides.txt_item_archived = '项目已归档'; +zhCNOverrides.txt_item_unarchived = '项目已取消归档'; +zhCNOverrides.txt_archived_selected_items = '已归档所选项目'; +zhCNOverrides.txt_unarchived_selected_items = '已取消归档所选项目'; +zhCNOverrides.txt_archive_item_failed = '归档项目失败'; +zhCNOverrides.txt_unarchive_item_failed = '取消归档项目失败'; +zhCNOverrides.txt_bulk_archive_failed = '批量归档失败'; +zhCNOverrides.txt_bulk_unarchive_failed = '批量取消归档失败'; +zhCNOverrides.txt_unarchive = '取消归档'; zhCNOverrides.txt_delete_folder = '删除文件夹'; zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。'; zhCNOverrides.txt_delete_all_folders = '删除全部文件夹'; diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index fef3e1b..223b9b6 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -143,6 +143,7 @@ export interface Cipher { creationDate?: string; revisionDate?: string; deletedDate?: string | null; + archivedDate?: string | null; attachments?: CipherAttachment[] | null; login?: CipherLogin | null; card?: CipherCard | null;