feat: add archiving functionality for ciphers

- Introduced `archive` and `unarchive` endpoints in the API for ciphers.
- Implemented bulk archiving and unarchiving of ciphers in the vault.
- Updated the storage schema to include `archived_at` timestamps for ciphers.
- Enhanced user interface to support archiving actions in the vault.
- Added necessary translations for archive-related actions.
- Updated user and device models to accommodate new fields related to archiving.
This commit is contained in:
shuaiplus
2026-03-23 01:10:48 +08:00
parent b50673f7d9
commit f7b5534cd0
28 changed files with 1179 additions and 106 deletions
+7
View File
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -51,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers (
key TEXT, key TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT, deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 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_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 INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS folders ( CREATE TABLE IF NOT EXISTS folders (
@@ -144,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices (
device_identifier TEXT NOT NULL, device_identifier TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
type INTEGER 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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier), PRIMARY KEY (user_id, device_identifier),
+46
View File
@@ -75,6 +75,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null; return null;
} }
async function verifyUserSecret(
auth: AuthService,
user: User,
secret: string | null | undefined
): Promise<boolean> {
const normalized = String(secret || '').trim();
if (!normalized) return false;
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
}
function toProfile(user: User, env: Env): ProfileResponse { function toProfile(user: User, env: Env): ProfileResponse {
void env; void env;
return { return {
@@ -98,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role, role: user.role,
status: user.status, status: user.status,
object: 'profile', object: 'profile',
@@ -194,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(), securityStamp: generateUUID(),
role: 'user', role: 'user',
status: 'active', status: 'active',
verifyDevices: true,
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
createdAt: now, createdAt: now,
@@ -363,6 +375,40 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
return jsonResponse(toProfile(user, env)); return jsonResponse(toProfile(user, env));
} }
// PUT/POST /api/accounts/verify-devices
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
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 // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
+159 -6
View File
@@ -26,6 +26,31 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined }; 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 { function looksLikeCipherString(value: unknown): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); 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 } options?: { omitFido2Credentials?: boolean }
): CipherResponse { ): CipherResponse {
// Strip internal-only fields that must not appear in the API response // 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 normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
@@ -163,7 +188,7 @@ export function cipherToResponse(
creationDate: createdAt, creationDate: createdAt,
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
archivedDate: null, archivedDate: archivedAt ?? null,
edit: true, edit: true,
viewPassword: true, viewPassword: true,
permissions: { permissions: {
@@ -273,12 +298,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt || 0, reprompt: cipherData.reprompt || 0,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -331,10 +356,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - 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') { } else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null; cipher.fields = null;
} }
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -376,6 +401,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// Soft delete // Soft delete
cipher.deletedAt = new Date().toISOString(); cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt; cipher.updatedAt = cipher.deletedAt;
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -441,6 +467,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
cipher.deletedAt = null; cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString(); cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -479,6 +506,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
cipher.favorite = body.favorite; cipher.favorite = body.favorite;
} }
cipher.updatedAt = new Date().toISOString(); cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); 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 }); return new Response(null, { status: 204 });
} }
async function buildCipherListResponse(
request: Request,
storage: StorageService,
userId: string,
ids: string[]
): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 // POST /api/ciphers/delete - Bulk soft delete
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> { export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
+302 -20
View File
@@ -1,3 +1,4 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types'; import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
@@ -5,6 +6,101 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid'; 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<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): 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<any> {
try {
return await request.json();
} catch {
return null;
}
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - 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); const devices = await storage.getDevicesByUserId(userId);
return jsonResponse({ return jsonResponse({
data: devices.map(device => ({ data: devices.map((device) => buildDeviceResponse(device)),
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
object: 'device',
})),
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
}); });
} }
// GET /api/devices/identifier/:deviceIdentifier
export async function handleGetDeviceByIdentifier(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
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<Response> {
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
// GET /api/devices/authorized // GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry. // Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
@@ -64,12 +182,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
knownIdentifiers.add(device.deviceIdentifier); knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier); const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return { return {
id: device.deviceIdentifier, ...buildDeviceResponse(device),
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
online: onlineSet.has(device.deviceIdentifier), online: onlineSet.has(device.deviceIdentifier),
trusted: !!trustedInfo, trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0, trustedTokenCount: trustedInfo?.tokenCount || 0,
@@ -80,13 +193,22 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
for (const row of trusted) { for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue; if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({ const placeholderDevice: Device = {
id: row.deviceIdentifier, userId,
deviceIdentifier: row.deviceIdentifier,
name: 'Unknown device', name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14, type: 14,
creationDate: '', sessionStamp: '',
revisionDate: '', encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
online: onlineSet.has(row.deviceIdentifier), online: onlineSet.has(row.deviceIdentifier),
trusted: true, trusted: true,
trustedTokenCount: row.tokenCount, 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 }); 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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 // PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint. // Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op. // 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 }); 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<Response> {
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<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
+23 -6
View File
@@ -31,6 +31,28 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null; return null;
} }
function buildPreloginResponse(
email: string,
kdfType: number,
kdfIterations: number,
kdfMemory: number | null,
kdfParallelism: number | null
): Record<string, unknown> {
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 { function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE] ? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
@@ -426,12 +448,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null; const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null; const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({ return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
kdf: kdfType,
kdfIterations: kdfIterations,
kdfMemory: kdfMemory,
kdfParallelism: kdfParallelism,
});
} }
// POST /identity/connect/revocation // POST /identity/connect/revocation
+5 -3
View File
@@ -232,6 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
key: (c as any).key ?? null, key: (c as any).key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: null,
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
@@ -245,10 +246,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const data = JSON.stringify(cipher); const data = JSON.stringify(cipher);
return env.DB return env.DB
.prepare( .prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + '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'
) )
.bind( .bind(
cipher.id, cipher.id,
@@ -263,6 +264,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
bindNull(cipher.key), bindNull(cipher.key),
cipher.createdAt, cipher.createdAt,
cipher.updatedAt, cipher.updatedAt,
bindNull(cipher.archivedAt),
bindNull(cipher.deletedAt) bindNull(cipher.deletedAt)
); );
}); });
+7
View File
@@ -148,6 +148,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile', object: 'profile',
}; };
@@ -180,6 +181,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}, },
policies: [], policies: [],
sends: sends.map(sendToResponse), sends: sends.map(sendToResponse),
UserDecryption: {
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
Object: 'userDecryption',
},
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
UserDecryptionOptions: buildUserDecryptionOptions(user), UserDecryptionOptions: buildUserDecryptionOptions(user),
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+19
View File
@@ -7,6 +7,7 @@ import {
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
handleChangePassword, handleChangePassword,
handleSetVerifyDevices,
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode, handleGetTotpRecoveryCode,
@@ -20,11 +21,15 @@ import {
handleDeleteCipherCompat, handleDeleteCipherCompat,
handlePermanentDeleteCipher, handlePermanentDeleteCipher,
handleRestoreCipher, handleRestoreCipher,
handleBulkArchiveCiphers,
handlePartialUpdateCipher, handlePartialUpdateCipher,
handleBulkUnarchiveCiphers,
handleBulkMoveCiphers, handleBulkMoveCiphers,
handleBulkDeleteCiphers, handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers, handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers, handleBulkRestoreCiphers,
handleArchiveCipher,
handleUnarchiveCipher,
} from './handlers/ciphers'; } from './handlers/ciphers';
import { import {
handleGetFolders, handleGetFolders,
@@ -110,6 +115,10 @@ export async function handleAuthenticatedRoute(
return handleVerifyPassword(request, env, userId); return handleVerifyPassword(request, env, userId);
} }
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
return handleSetVerifyDevices(request, env, userId);
}
if (path === '/api/sync' && method === 'GET') { if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId); return handleSync(request, env, userId);
} }
@@ -140,6 +149,14 @@ export async function handleAuthenticatedRoute(
return handleBulkRestoreCiphers(request, env, userId); return handleBulkRestoreCiphers(request, env, userId);
} }
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
return handleBulkArchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
return handleBulkUnarchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) { if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
return handleBulkMoveCiphers(request, env, userId); return handleBulkMoveCiphers(request, env, userId);
} }
@@ -158,6 +175,8 @@ export async function handleAuthenticatedRoute(
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId); 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') return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(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);
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId); if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId); if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId); if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
+60 -3
View File
@@ -1,12 +1,21 @@
import type { Env } from './types'; import type { Env } from './types';
import { import {
handleGetAuthorizedDevices, handleGetAuthorizedDevices,
handleGetDevice,
handleGetDevices, handleGetDevices,
handleGetDeviceByIdentifier,
handleUpdateDeviceKeys,
handleUpdateDeviceTrust,
handleUntrustDevices,
handleRetrieveDeviceKeys,
handleDeactivateDevice,
handleRevokeAllTrustedDevices, handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
} from './handlers/devices'; } from './handlers/devices';
export async function handleAuthenticatedDeviceRoute( export async function handleAuthenticatedDeviceRoute(
@@ -35,16 +44,64 @@ export async function handleAuthenticatedDeviceRoute(
} }
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
}
if (deleteDeviceMatch && method === 'DELETE') { if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
}
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
}
if (path === '/api/devices/update-trust' && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId);
}
if (path === '/api/devices/untrust' && method === 'POST') {
return handleUntrustDevices(request, env, userId);
}
return null; return null;
} }
+49 -22
View File
@@ -78,6 +78,43 @@ function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`; return `img-src 'self' data: ${origin}`;
} }
function buildConfigResponse(origin: string) {
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, ''); const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null; if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
@@ -243,6 +280,11 @@ export async function handlePublicRoute(
return handleKnownDevice(request, env); return handleKnownDevice(request, env);
} }
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
return new Response(null, { status: 200 });
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') { if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
@@ -255,6 +297,12 @@ export async function handlePublicRoute(
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env); return handleRecoverTwoFactor(request, env);
} }
@@ -275,28 +323,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse({ return jsonResponse(buildConfigResponse(origin));
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'unauth-ui-refresh': true,
},
object: 'config',
});
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
+78 -9
View File
@@ -17,6 +17,7 @@ interface CipherRow {
key: string | null; key: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
archived_at: string | null;
deleted_at: string | null; deleted_at: string | null;
} }
@@ -37,6 +38,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
key: row.key ?? parsed.key ?? null, key: row.key ?? parsed.key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null, deletedAt: row.deleted_at ?? null,
}; };
} catch { } catch {
@@ -46,7 +48,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
} }
function selectCipherColumns(): string { function selectCipherColumns(): string {
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at'; return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
} }
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> { export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
@@ -60,10 +62,10 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> { export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const data = JSON.stringify(cipher); const data = JSON.stringify(cipher);
const stmt = db.prepare( 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) ' + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + '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( await safeBind(
stmt, stmt,
@@ -79,10 +81,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
cipher.key, cipher.key,
cipher.createdAt, cipher.createdAt,
cipher.updatedAt, cipher.updatedAt,
cipher.archivedAt ?? null,
cipher.deletedAt cipher.deletedAt
).run(); ).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<void> { export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); 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 userId: string
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; 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; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -126,7 +133,7 @@ export async function bulkRestoreCiphers(
userId: string userId: string
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; 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; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -157,7 +164,7 @@ export async function bulkDeleteCiphers(
userId: string userId: string
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; 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; if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1); const chunkSize = sqlChunkSize(1);
@@ -212,7 +219,7 @@ export async function getCiphersByIds(
userId: string userId: string
): Promise<Cipher[]> { ): Promise<Cipher[]> {
if (ids.length === 0) return []; 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 []; if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1); const chunkSize = sqlChunkSize(1);
@@ -242,7 +249,7 @@ export async function bulkMoveCiphers(
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; if (ids.length === 0) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const uniqueIds = Array.from(new Set(ids)); const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId, updatedAt: now }); const patch = JSON.stringify({ folderId, updatedAt: now });
const chunkSize = sqlChunkSize(4); const chunkSize = sqlChunkSize(4);
@@ -261,3 +268,65 @@ export async function bulkMoveCiphers(
return updateRevisionDate(userId); return updateRevisionDate(userId);
} }
export async function bulkArchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
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<string | null> {
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);
}
+82 -6
View File
@@ -10,6 +10,9 @@ function mapDeviceRow(row: any): Device {
name: row.name, name: row.name,
type: row.type, type: row.type,
sessionStamp: row.session_stamp || '', 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, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -22,19 +25,92 @@ export async function upsertDevice(
deviceIdentifier: string, deviceIdentifier: string,
name: string, name: string,
type: number, type: number,
sessionStamp?: string sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + '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, updated_at=excluded.updated_at' '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(); .run();
} }
export async function updateDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
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<number> {
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<boolean> { export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const row = await db const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') .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<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .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' 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
) )
.bind(userId) .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<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .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' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
+9 -3
View File
@@ -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, ' + '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, ' + '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, ' + '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 master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', '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 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_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code 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 (' + '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, ' + '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, ' + '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)', '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_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 INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE TABLE IF NOT EXISTS folders (' + '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 INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + '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, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', '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)', '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 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 INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
+15 -14
View File
@@ -1,6 +1,10 @@
import type { User } from '../types'; import type { User } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; 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 { function mapUserRow(row: any): User {
return { return {
@@ -19,6 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
createdAt: row.created_at, createdAt: row.created_at,
@@ -28,9 +33,7 @@ function mapUserRow(row: any): User {
export async function getUser(db: D1Database, email: string): Promise<User | null> { export async function getUser(db: D1Database, email: string): Promise<User | null> {
const row = await db const row = await db
.prepare( .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
'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 = ?'
)
.bind(email.toLowerCase()) .bind(email.toLowerCase())
.first<any>(); .first<any>();
if (!row) return null; if (!row) return null;
@@ -39,9 +42,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
export async function getUserById(db: D1Database, id: string): Promise<User | null> { export async function getUserById(db: D1Database, id: string): Promise<User | null> {
const row = await db const row = await db
.prepare( .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
'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 = ?'
)
.bind(id) .bind(id)
.first<any>(); .first<any>();
if (!row) return null; if (!row) return null;
@@ -55,9 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
export async function getAllUsers(db: D1Database): Promise<User[]> { export async function getAllUsers(db: D1Database): Promise<User[]> {
const res = await db const res = await db
.prepare( .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
'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'
)
.all<any>(); .all<any>();
return (res.results || []).map((row) => mapUserRow(row)); return (res.results || []).map((row) => mapUserRow(row));
} }
@@ -65,11 +64,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( 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) ' + '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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + '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, ' + '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( await safeBind(
stmt, stmt,
@@ -88,6 +87,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.securityStamp, user.securityStamp,
user.role, user.role,
user.status, user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.createdAt, 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<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( 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) ' + '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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -123,6 +123,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.securityStamp, user.securityStamp,
user.role, user.role,
user.status, user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.createdAt, user.createdAt,
+42 -3
View File
@@ -36,10 +36,12 @@ import {
saveFolder as saveStoredFolder, saveFolder as saveStoredFolder,
} from './storage-folder-repo'; } from './storage-folder-repo';
import { import {
bulkArchiveCiphers as archiveStoredCiphers,
bulkDeleteCiphers as deleteStoredCiphers, bulkDeleteCiphers as deleteStoredCiphers,
bulkMoveCiphers as moveStoredCiphers, bulkMoveCiphers as moveStoredCiphers,
bulkRestoreCiphers as restoreStoredCiphers, bulkRestoreCiphers as restoreStoredCiphers,
bulkSoftDeleteCiphers as softDeleteStoredCiphers, bulkSoftDeleteCiphers as softDeleteStoredCiphers,
bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers, getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher, getCipher as findStoredCipher,
getCiphersByIds as listStoredCiphersByIds, getCiphersByIds as listStoredCiphersByIds,
@@ -80,6 +82,7 @@ import {
import { import {
deleteDevice as deleteStoredDevice, deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId, deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice, getDevice as findStoredDevice,
@@ -90,6 +93,7 @@ import {
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
@@ -102,7 +106,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; 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. // D1-backed storage.
// Contract: // Contract:
@@ -286,6 +290,14 @@ export class StorageService {
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
} }
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> { async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
} }
@@ -495,8 +507,19 @@ export class StorageService {
// --- Devices --- // --- Devices ---
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> { async upsertDevice(
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp); userId: string,
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
} }
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> { async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
@@ -515,6 +538,22 @@ export class StorageService {
return findStoredDevice(this.db, userId, deviceIdentifier); return findStoredDevice(this.db, userId, deviceIdentifier);
} }
async updateDeviceKeys(
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> { async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
return deleteStoredDevice(this.db, userId, deviceIdentifier); return deleteStoredDevice(this.db, userId, deviceIdentifier);
} }
+47
View File
@@ -47,6 +47,7 @@ export interface User {
securityStamp: string; securityStamp: string;
role: UserRole; role: UserRole;
status: UserStatus; status: UserStatus;
verifyDevices?: boolean;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: string | null; totpRecoveryCode: string | null;
createdAt: string; createdAt: string;
@@ -169,6 +170,7 @@ export interface Cipher {
key: string | null; key: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
archivedAt: string | null;
deletedAt: string | null; deletedAt: string | null;
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */ /** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
[key: string]: any; [key: string]: any;
@@ -189,10 +191,47 @@ export interface Device {
name: string; name: string;
type: number; type: number;
sessionStamp: string; sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
createdAt: string; createdAt: string;
updatedAt: 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 { export interface RefreshTokenRecord {
userId: string; userId: string;
expiresAt: number; expiresAt: number;
@@ -351,6 +390,7 @@ export interface ProfileResponse {
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; creationDate: string;
verifyDevices?: boolean;
role?: UserRole; role?: UserRole;
status?: UserStatus; status?: UserStatus;
object: string; object: string;
@@ -409,6 +449,13 @@ export interface SyncResponse {
domains: any; domains: any;
policies: any[]; policies: any[];
sends: SendResponse[]; sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
Object?: string;
} | null;
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null; UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+4
View File
@@ -974,9 +974,13 @@ export default function App() {
onCreateVaultItem: vaultSendActions.createVaultItem, onCreateVaultItem: vaultSendActions.createVaultItem,
onUpdateVaultItem: vaultSendActions.updateVaultItem, onUpdateVaultItem: vaultSendActions.updateVaultItem,
onDeleteVaultItem: vaultSendActions.deleteVaultItem, onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkArchiveVaultItems: vaultSendActions.bulkArchiveVaultItems,
onBulkUnarchiveVaultItems: vaultSendActions.bulkUnarchiveVaultItems,
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems, onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword, onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
onCreateFolder: vaultSendActions.createFolder, onCreateFolder: vaultSendActions.createFolder,
+8
View File
@@ -64,9 +64,13 @@ export interface AppMainRoutesProps {
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>; onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>; onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDeleteVaultItem: (cipher: Cipher) => Promise<void>; onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>; onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>; onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onCreateFolder: (name: string) => Promise<void>; onCreateFolder: (name: string) => Promise<void>;
@@ -174,9 +178,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreate={props.onCreateVaultItem} onCreate={props.onCreateVaultItem}
onUpdate={props.onUpdateVaultItem} onUpdate={props.onUpdateVaultItem}
onDelete={props.onDeleteVaultItem} onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems} onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems} onBulkRestore={props.onBulkRestoreVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems} onBulkMove={props.onBulkMoveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword} onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify} onNotify={props.onNotify}
+2 -3
View File
@@ -4,7 +4,7 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto'; import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; 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 { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -82,8 +82,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
() => () =>
props.ciphers props.ciphers
.filter((cipher) => { .filter((cipher) => {
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); return isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp;
return !isDeleted && !!cipher.login?.decTotp;
}) })
.sort((a, b) => { .sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameA = (a.decName || a.name || '').trim().toLowerCase();
+44 -5
View File
@@ -17,6 +17,9 @@ import {
buildCipherDuplicateSignature, buildCipherDuplicateSignature,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
sortTimeValue, sortTimeValue,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
@@ -36,9 +39,13 @@ interface VaultPageProps {
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>; onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>; onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>; onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>; onBulkRestore: (ids: string[]) => Promise<void>;
onBulkArchive: (ids: string[]) => Promise<void>;
onBulkUnarchive: (ids: string[]) => Promise<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>; onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -229,8 +236,7 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureCounts = useMemo(() => { const duplicateSignatureCounts = useMemo(() => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); if (!isCipherVisibleInNormalVault(cipher)) continue;
if (isDeleted) continue;
const signature = buildCipherDuplicateSignature(cipher); const signature = buildCipherDuplicateSignature(cipher);
counts.set(signature, (counts.get(signature) || 0) + 1); counts.set(signature, (counts.get(signature) || 0) + 1);
} }
@@ -239,11 +245,12 @@ export default function VaultPage(props: VaultPageProps) {
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
if (sidebarFilter.kind === 'trash') { if (sidebarFilter.kind === 'trash') {
if (!isDeleted) return false; if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (isDeleted) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
return false; return false;
} }
@@ -677,6 +684,34 @@ function folderName(id: string | null | undefined): string {
} }
} }
async function confirmBulkArchive(): Promise<void> {
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<void> {
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<void> { async function confirmDeleteAllFolders(): Promise<void> {
if (!props.folders.length) return; if (!props.folders.length) return;
setBusy(true); setBusy(true);
@@ -760,6 +795,8 @@ function folderName(id: string | null | undefined): string {
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)} onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onStartCreate={startCreate} onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()} onBulkRestore={() => void confirmBulkRestore()}
onBulkArchive={() => void confirmBulkArchive()}
onBulkUnarchive={() => void confirmBulkUnarchive()}
onOpenMove={() => { onOpenMove={() => {
setMoveFolderId('__none__'); setMoveFolderId('__none__');
setMoveOpen(true); setMoveOpen(true);
@@ -851,6 +888,8 @@ function folderName(id: string | null | undefined): string {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit} onStartEdit={startEdit}
onDelete={setPendingDelete} onDelete={setPendingDelete}
onArchive={props.onArchive}
onUnarchive={props.onUnarchive}
/> />
)} )}
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks'; 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 type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -31,11 +31,14 @@ interface VaultDetailViewProps {
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onStartEdit: () => void; onStartEdit: () => void;
onDelete: (cipher: Cipher) => void; onDelete: (cipher: Cipher) => void;
onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>;
} }
export default function VaultDetailView(props: VaultDetailViewProps) { export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -62,6 +65,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3> <h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div> <div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
</div> </div>
{props.selectedCipher.login && ( {props.selectedCipher.login && (
@@ -351,6 +355,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}> <button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')} <Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button> </button>
{isArchived ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
</button>
)}
</div> </div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}> <button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')} <Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
+14 -2
View File
@@ -1,5 +1,5 @@
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -48,6 +48,8 @@ interface VaultListPanelProps {
onToggleCreateMenu: () => void; onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void; onStartCreate: (type: number) => void;
onBulkRestore: () => void; onBulkRestore: () => void;
onBulkArchive: () => void;
onBulkUnarchive: () => void;
onOpenMove: () => void; onOpenMove: () => void;
onClearSelection: () => void; onClearSelection: () => void;
onScroll: (top: number) => void; onScroll: (top: number) => void;
@@ -139,7 +141,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')} <RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button> </button>
)} )}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && ( {props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}> <button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')} <FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button> </button>
@@ -1,4 +1,5 @@
import { import {
Archive,
Copy, Copy,
CreditCard, CreditCard,
Folder as FolderIcon, Folder as FolderIcon,
@@ -48,6 +49,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span> <Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button> </button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span> <Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button> </button>
@@ -16,6 +16,7 @@ export type VaultSortMode = 'edited' | 'created' | 'name';
export type SidebarFilter = export type SidebarFilter =
| { kind: 'all' } | { kind: 'all' }
| { kind: 'favorite' } | { kind: 'favorite' }
| { kind: 'archive' }
| { kind: 'trash' } | { kind: 'trash' }
| { kind: 'duplicates' } | { kind: 'duplicates' }
| { kind: 'type'; value: TypeFilter } | { kind: 'type'; value: TypeFilter }
@@ -71,6 +72,34 @@ export function cipherTypeKey(type: number): TypeFilter {
return 'ssh'; 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 { export function cipherTypeLabel(type: number): string {
if (type === 1) return t('txt_login'); if (type === 1) return t('txt_login');
if (type === 3) return t('txt_card'); if (type === 3) return t('txt_card');
+47
View File
@@ -22,7 +22,9 @@ import {
} from '@/lib/app-support'; } from '@/lib/app-support';
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send'; import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
import { import {
archiveCipher,
buildCipherImportPayload, buildCipherImportPayload,
bulkArchiveCiphers,
bulkDeleteCiphers, bulkDeleteCiphers,
bulkDeleteFolders, bulkDeleteFolders,
bulkMoveCiphers, bulkMoveCiphers,
@@ -40,6 +42,7 @@ import {
type CiphersImportPayload, type CiphersImportPayload,
type ImportedCipherMapEntry, type ImportedCipherMapEntry,
updateCipher, updateCipher,
unarchiveCipher,
uploadCipherAttachment, uploadCipherAttachment,
} from '@/lib/api/vault'; } from '@/lib/api/vault';
import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth'; 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[]) { async bulkDeleteVaultItems(ids: string[]) {
try { try {
await bulkDeleteCiphers(authedFetch, ids); 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) { async bulkMoveVaultItems(ids: string[], folderId: string | null) {
try { try {
await bulkMoveCiphers(authedFetch, ids, folderId); await bulkMoveCiphers(authedFetch, ids, folderId);
+38
View File
@@ -582,6 +582,20 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
if (!resp.ok) throw new Error('Delete item failed'); if (!resp.ok) throw new Error('Delete item failed');
} }
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
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<void> {
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<void> { export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { 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<void> {
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<void> { export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { 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<void> {
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( export async function bulkMoveCiphers(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
ids: string[], ids: string[],
+24
View File
@@ -280,6 +280,18 @@ const messages: Record<Locale, Record<string, string>> = {
txt_delete_item: "Delete Item", txt_delete_item: "Delete Item",
txt_delete_item_failed: "Delete item failed", txt_delete_item_failed: "Delete item failed",
txt_delete_permanently: "Delete Permanently", 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: "Delete Selected",
txt_delete_selected_items: "Delete Selected Items", txt_delete_selected_items: "Delete Selected Items",
txt_delete_selected_items_permanently: "Delete Selected Items Permanently", 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_import_export_title = '导入导出';
zhCNOverrides.txt_new_type_header = '新建{type}'; zhCNOverrides.txt_new_type_header = '新建{type}';
zhCNOverrides.txt_edit_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 = '删除文件夹';
zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。'; zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
zhCNOverrides.txt_delete_all_folders = '删除全部文件夹'; zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
+1
View File
@@ -143,6 +143,7 @@ export interface Cipher {
creationDate?: string; creationDate?: string;
revisionDate?: string; revisionDate?: string;
deletedDate?: string | null; deletedDate?: string | null;
archivedDate?: string | null;
attachments?: CipherAttachment[] | null; attachments?: CipherAttachment[] | null;
login?: CipherLogin | null; login?: CipherLogin | null;
card?: CipherCard | null; card?: CipherCard | null;