mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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:
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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"))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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 = '删除全部文件夹';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user