Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance

- Removed password breach checking logic and related state management from VaultPage.
- Introduced bulk delete functionality for folders with a confirmation dialog.
- Enhanced list rendering with virtualization to improve performance.
- Updated styles for folder actions and list items for better UI consistency.
- Removed unused password breach library and related translations.
This commit is contained in:
shuaiplus
2026-03-11 02:22:35 +08:00
parent bc5efbf2fd
commit f4d2e7932a
11 changed files with 491 additions and 490 deletions
+23
View File
@@ -518,3 +518,26 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return new Response(null, { status: 204 });
}
// POST /api/ciphers/delete - Bulk soft delete
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
+24
View File
@@ -136,3 +136,27 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
return new Response(null, { status: 204 });
}
// POST /api/folders/delete
export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
if (!ids.length) {
return errorResponse('Folder ids are required', 400);
}
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
+33
View File
@@ -1025,6 +1025,39 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
return new Response(null, { status: 200 });
}
// POST /api/sends/delete - Bulk delete
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const sends = await storage.getSendsByIds(body.ids, userId);
for (const send of sends) {
if (send.type !== SendType.File) continue;
const data = parseStoredSendData(send);
const fileId = typeof data.id === 'string' ? data.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
}
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 200 });
}
// PUT /api/sends/:id/remove-password
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
+31 -17
View File
@@ -34,6 +34,7 @@ import {
handleRestoreCipher,
handlePartialUpdateCipher,
handleBulkMoveCiphers,
handleBulkDeleteCiphers,
} from './handlers/ciphers';
// Folder handlers
@@ -42,7 +43,8 @@ import {
handleGetFolder,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder
handleDeleteFolder,
handleBulkDeleteFolders,
} from './handlers/folders';
// Send handlers
@@ -55,6 +57,7 @@ import {
handleUploadSendFile,
handleUpdateSend,
handleDeleteSend,
handleBulkDeleteSends,
handleRemoveSendPassword,
handleRemoveSendAuth,
handleAccessSend,
@@ -141,6 +144,18 @@ function getNwIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
if (method === 'POST') {
if (path === '/api/ciphers/import') return true;
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true;
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true;
}
return false;
}
function handleNwFavicon(): Response {
return new Response(getNwIconSvg(), {
status: 200,
@@ -151,17 +166,6 @@ function handleNwFavicon(): Response {
});
}
const BITWARDEN_DEFAULT_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', buffer);
return bytesToHex(new Uint8Array(digest));
}
function isValidIconHostname(hostname: string): boolean {
if (!hostname) return false;
if (hostname.length > 253) return false;
@@ -183,7 +187,7 @@ function isValidIconHostname(hostname: string): boolean {
});
}
// Icons handler - proxy to Bitwarden's official icon service
// Icons handler - proxy to favicon.im
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
try {
void env;
@@ -199,8 +203,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
return cached;
}
// Use Bitwarden's official icon service
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
const iconUrl = `https://favicon.im/${normalizedHostname}`;
const resp = await fetch(iconUrl, {
headers: { 'User-Agent': 'NodeWarden/1.0' },
redirect: 'follow',
@@ -212,7 +215,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
if (resp.ok) {
const body = await resp.arrayBuffer();
if (body.byteLength === 500 && (await sha256Hex(body)) === BITWARDEN_DEFAULT_ICON_SHA256) {
if (body.byteLength === 0) {
return new Response(null, { status: 204 });
}
const iconResponse = new Response(body, {
@@ -512,7 +515,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return errorResponse('Account is disabled', 403);
}
// Unified rate limiting for all authenticated API requests.
{
if (!isImportBypassRequest(request, path, method)) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeBudget(
userId + ':api',
@@ -600,6 +603,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleCiphersImport(request, env, userId);
}
if (path === '/api/ciphers/delete' && method === 'POST') {
return handleBulkDeleteCiphers(request, env, userId);
}
// Bulk cipher operations (only move is allowed)
if (path === '/api/ciphers/move') {
if (method === 'POST' || method === 'PUT') {
@@ -677,6 +684,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (method === 'GET') return handleGetFolders(request, env, userId);
if (method === 'POST') return handleCreateFolder(request, env, userId);
}
if (path === '/api/folders/delete' && method === 'POST') {
return handleBulkDeleteFolders(request, env, userId);
}
// Match /api/folders/:id patterns
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
@@ -712,6 +722,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (method === 'POST') return handleCreateSend(request, env, userId);
}
if (path === '/api/sends/delete' && method === 'POST') {
return handleBulkDeleteSends(request, env, userId);
}
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
return handleCreateFileSendV2(request, env, userId);
}
+99 -1
View File
@@ -469,6 +469,34 @@ export class StorageService {
await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({
deletedAt: now,
updatedAt: now,
});
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await this.db
.prepare(
`UPDATE ciphers
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, now, patch, userId, ...chunk)
.run();
}
return this.updateRevisionDate(userId);
}
async getAllCiphers(userId: string): Promise<Cipher[]> {
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
return (res.results || []).flatMap(r => {
@@ -512,7 +540,7 @@ export class StorageService {
folderId,
updatedAt: now,
});
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -562,6 +590,42 @@ export class StorageService {
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async bulkDeleteFolders(ids: string[], userId: string): Promise<string | null> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
const now = new Date().toISOString();
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await this.db
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
.bind(userId, ...chunk)
.all<{ data: string }>();
for (const row of res.results || []) {
let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await this.saveCipher(cipher);
}
await this.db
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
.run();
}
return this.updateRevisionDate(userId);
}
// Clear folder references from all ciphers owned by the user.
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
@@ -928,6 +992,40 @@ export class StorageService {
await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async getSendsByIds(ids: string[], userId: string): Promise<Send[]> {
if (ids.length === 0) return [];
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return [];
const placeholders = uniqueIds.map(() => '?').join(',');
const res = await this.db
.prepare(
`SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date
FROM sends
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(userId, ...uniqueIds)
.all<any>();
return (res.results || []).map((row) => this.mapSendRow(row));
}
async bulkDeleteSends(ids: string[], userId: string): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await this.db
.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
.run();
}
return this.updateRevisionDate(userId);
}
async getAllSends(userId: string): Promise<Send[]> {
const res = await this.db
.prepare(
+68 -76
View File
@@ -18,15 +18,20 @@ import ImportPage from '@/components/ImportPage';
import TotpCodesPage from '@/components/TotpCodesPage';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import {
buildCipherImportPayload,
bulkDeleteFolders,
changeMasterPassword,
createFolder,
updateFolder,
deleteCipherAttachment,
deleteFolder,
bulkDeleteCiphers,
bulkDeleteSends,
createCipher,
createAuthedFetch,
createInvite,
downloadCipherAttachmentDecrypted,
encryptFolderImportName,
exportAdminBackup,
importAdminBackup,
importCiphers,
@@ -450,6 +455,14 @@ export default function App() {
),
[session, setupRegistered]
);
const importAuthedFetch = useMemo(
() => async (input: string, init?: RequestInit) => {
const headers = new Headers(init?.headers || {});
headers.set('X-NodeWarden-Import', '1');
return authedFetch(input, { ...init, headers });
},
[authedFetch]
);
useEffect(() => {
let mounted = true;
@@ -1208,9 +1221,7 @@ export default function App() {
async function bulkDeleteVaultItems(ids: string[]) {
try {
for (const id of ids) {
await deleteCipher(authedFetch, id);
}
await bulkDeleteCiphers(authedFetch, ids);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', t('txt_deleted_selected_items'));
} catch (error) {
@@ -1287,9 +1298,7 @@ export default function App() {
async function bulkDeleteSendItems(ids: string[]) {
try {
for (const id of ids) {
await deleteSend(authedFetch, id);
}
await bulkDeleteSends(authedFetch, ids);
await sendsQuery.refetch();
pushToast('success', t('txt_deleted_selected_sends'));
} catch (error) {
@@ -1336,18 +1345,17 @@ export default function App() {
}
}
function buildImportedCipherMaps(
payloadCiphers: Array<Record<string, unknown>>,
createdCipherIdsByIndex: Map<number, string>
): { byIndex: Map<number, string>; bySourceId: Map<string, string> } {
const byIndex = new Map<number, string>(createdCipherIdsByIndex);
const bySourceId = new Map<string, string>();
for (const [index, id] of createdCipherIdsByIndex.entries()) {
const raw = (payloadCiphers[index] || {}) as Record<string, unknown>;
const sourceId = String(raw.id || '').trim();
if (sourceId) bySourceId.set(sourceId, id);
async function bulkDeleteFoldersAction(ids: string[]) {
const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!folderIds.length) return;
try {
await bulkDeleteFolders(authedFetch, folderIds);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', t('txt_folders_deleted'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
throw error;
}
return { byIndex, bySourceId };
}
async function uploadImportedAttachments(
@@ -1383,7 +1391,7 @@ export default function App() {
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
const cipher = cipherById.get(targetCipherId) || null;
try {
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher);
imported += 1;
} catch (error) {
failed.push({
@@ -1426,82 +1434,65 @@ export default function App() {
const mode = options.folderMode || 'original';
const targetFolderId = (options.targetFolderId || '').trim() || null;
const folderIdByCipherIndex = new Map<number, string>();
let createdFolderCount = 0;
const nextPayload: CiphersImportPayload = {
ciphers: [],
folders: [],
folderRelationships: [],
};
if (mode === 'original') {
const folderIdByImportIndex = new Map<number, string>();
const folderIdByLegacyId = new Map<string, string>();
const folderIdByName = new Map<string, string>();
const createdFolderIdByName = new Map<string, string>();
const folderIndexByLegacyId = new Map<string, number>();
const folderIndexByName = new Map<string, number>();
for (let i = 0; i < payload.folders.length; i++) {
const folderRaw = (payload.folders[i] || {}) as Record<string, unknown>;
const name = String(folderRaw.name || '').trim();
if (!name) continue;
let folderId = createdFolderIdByName.get(name) || null;
if (!folderId) {
const created = await createFolder(authedFetch, session, name);
folderId = created.id;
createdFolderIdByName.set(name, folderId);
createdFolderCount += 1;
let folderIndex = folderIndexByName.get(name);
if (folderIndex == null) {
folderIndex = nextPayload.folders.length;
nextPayload.folders.push({ name: await encryptFolderImportName(session, name) });
folderIndexByName.set(name, folderIndex);
}
folderIdByImportIndex.set(i, folderId);
folderIdByName.set(name, folderId);
const legacyId = String(folderRaw.id || '').trim();
if (legacyId) folderIdByLegacyId.set(legacyId, folderId);
}
for (const relation of payload.folderRelationships || []) {
const cipherIndex = Number(relation?.key);
const folderIndex = Number(relation?.value);
if (!Number.isFinite(cipherIndex) || !Number.isFinite(folderIndex)) continue;
const folderId = folderIdByImportIndex.get(folderIndex);
if (folderId) folderIdByCipherIndex.set(cipherIndex, folderId);
if (legacyId) folderIndexByLegacyId.set(legacyId, folderIndex);
}
for (let i = 0; i < payload.ciphers.length; i++) {
if (folderIdByCipherIndex.has(i)) continue;
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
const rawFolderId = String(raw.folderId || '').trim();
if (rawFolderId && folderIdByLegacyId.has(rawFolderId)) {
folderIdByCipherIndex.set(i, folderIdByLegacyId.get(rawFolderId)!);
continue;
let folderIndex: number | undefined;
for (const relation of payload.folderRelationships || []) {
const cipherIndex = Number(relation?.key);
const relFolderIndex = Number(relation?.value);
if (cipherIndex !== i || !Number.isFinite(relFolderIndex)) continue;
const importedFolder = payload.folders[relFolderIndex] as Record<string, unknown> | undefined;
const importedName = String(importedFolder?.name || '').trim();
if (importedName) folderIndex = folderIndexByName.get(importedName);
if (folderIndex != null) break;
}
const rawFolderName = String(raw.folder || '').trim();
if (rawFolderName && folderIdByName.has(rawFolderName)) {
folderIdByCipherIndex.set(i, folderIdByName.get(rawFolderName)!);
if (folderIndex == null) {
const rawFolderId = String(raw.folderId || '').trim();
if (rawFolderId) folderIndex = folderIndexByLegacyId.get(rawFolderId);
}
if (folderIndex == null) {
const rawFolderName = String(raw.folder || '').trim();
if (rawFolderName) folderIndex = folderIndexByName.get(rawFolderName);
}
if (folderIndex != null) {
nextPayload.folderRelationships.push({ key: i, value: folderIndex });
}
}
} else if (mode === 'target' && targetFolderId) {
for (let i = 0; i < payload.ciphers.length; i++) {
folderIdByCipherIndex.set(i, targetFolderId);
}
}
const createdCipherIdsByIndex = new Map<number, string>();
for (let i = 0; i < payload.ciphers.length; i++) {
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
const draft = importCipherToDraft(raw, null);
const created = await createCipher(authedFetch, session, draft);
createdCipherIdsByIndex.set(i, created.id);
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
nextPayload.ciphers.push(await buildCipherImportPayload(session, draft));
}
const moveIdsByFolderId = new Map<string, string[]>();
for (const [index, folderId] of folderIdByCipherIndex.entries()) {
const cipherId = createdCipherIdsByIndex.get(index);
if (!cipherId || !folderId) continue;
const group = moveIdsByFolderId.get(folderId) || [];
group.push(cipherId);
moveIdsByFolderId.set(folderId, group);
}
for (const [folderId, ids] of moveIdsByFolderId.entries()) {
await bulkMoveCiphers(authedFetch, ids, folderId);
}
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
await foldersQuery.refetch();
await ciphersQuery.refetch();
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
returnCipherMap: attachments.length > 0,
});
await Promise.all([foldersQuery.refetch(), ciphersQuery.refetch()]);
const attachmentSummary = attachments.length
? await uploadImportedAttachments(attachments, idMaps)
? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap))
: undefined;
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0, attachmentSummary);
return summarizeImportResult(payload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary);
}
async function handleImportEncryptedRawAction(
@@ -1522,7 +1513,7 @@ export default function App() {
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = targetFolderId;
}
const importedCipherMap = await importCiphers(authedFetch, nextPayload, {
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
returnCipherMap: attachments.length > 0,
});
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
@@ -2070,6 +2061,7 @@ export default function App() {
onNotify={pushToast}
onCreateFolder={createFolderAction}
onDeleteFolder={deleteFolderAction}
onBulkDeleteFolders={bulkDeleteFoldersAction}
onDownloadAttachment={downloadVaultAttachment}
/>
</Route>
+103 -151
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import { calcTotpNow } from '@/lib/crypto';
import { checkCipherPasswordsExposed } from '@/lib/password-breach';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import {
ArrowUpDown,
@@ -26,7 +25,6 @@ import {
Pencil,
Plus,
RefreshCw,
ShieldAlert,
ShieldUser,
Star,
StarOff,
@@ -53,6 +51,7 @@ interface VaultPageProps {
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
}
@@ -61,7 +60,6 @@ type VaultSortMode = 'edited' | 'created' | 'name';
type SidebarFilter =
| { kind: 'all' }
| { kind: 'favorite' }
| { kind: 'exposed' }
| { kind: 'trash' }
| { kind: 'type'; value: TypeFilter }
| { kind: 'folder'; folderId: string | null };
@@ -80,9 +78,9 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [
];
const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
const VAULT_EXPOSED_IGNORED_STORAGE_KEY = 'nodewarden.vault.exposed-ignored.v1';
const VAULT_EXPOSED_SIGNATURE_STORAGE_KEY = 'nodewarden.vault.exposed-signature.v1';
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
const VAULT_LIST_ROW_HEIGHT = 66;
const VAULT_LIST_OVERSCAN = 10;
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
@@ -336,11 +334,12 @@ function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const failedIconHosts = new Set<string>();
function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(false);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
if (host && !errored) {
return (
<img
@@ -348,7 +347,10 @@ function VaultListIcon({ cipher }: { cipher: Cipher }) {
src={`/icons/${host}/icon.png?v=2`}
alt=""
loading="lazy"
onError={() => setErrored(true)}
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
/>
);
}
@@ -371,43 +373,12 @@ function openUri(raw: string): void {
window.open(url, '_blank', 'noopener');
}
async function computePasswordSignature(ciphers: Cipher[]): Promise<string> {
const parts = ciphers
.filter((cipher) => Number(cipher.type || 1) === 1)
.map((cipher) => `${String(cipher.id || '').trim()}\u0000${String(cipher.login?.decPassword || '')}`)
.sort();
const bytes = new TextEncoder().encode(parts.join('\u0001'));
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function countVisibleExposed(results: Record<string, boolean>, ignoredMap: Record<string, boolean>): number {
let count = 0;
for (const [cipherId, exposed] of Object.entries(results)) {
if (exposed && !ignoredMap[cipherId]) count++;
}
return count;
}
function readIgnoredExposedMap(): Record<string, boolean> {
try {
const raw = localStorage.getItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, boolean>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export default function VaultPage(props: VaultPageProps) {
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [exposedStatusMap, setExposedStatusMap] = useState<Record<string, boolean>>({});
const [ignoredExposedMap, setIgnoredExposedMap] = useState<Record<string, boolean>>(() => readIgnoredExposedMap());
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -428,6 +399,7 @@ export default function VaultPage(props: VaultPageProps) {
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
@@ -442,13 +414,11 @@ export default function VaultPage(props: VaultPageProps) {
const createMenuRef = useRef<HTMLDivElement | null>(null);
const sortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
const hasCompletedAutoExposureCheckRef = useRef(false);
function isVisibleExposed(cipherId: string): boolean {
return !!exposedStatusMap[cipherId] && !ignoredExposedMap[cipherId];
}
const [listScrollTop, setListScrollTop] = useState(0);
const [listViewportHeight, setListViewportHeight] = useState(0);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
@@ -499,57 +469,14 @@ export default function VaultPage(props: VaultPageProps) {
}, [sortMode]);
useEffect(() => {
try {
localStorage.setItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY, JSON.stringify(ignoredExposedMap));
} catch {
// ignore storage write failures
}
}, [ignoredExposedMap]);
useEffect(() => {
if (props.loading) return;
const loginCiphers = props.ciphers.filter(
(cipher) => Number(cipher.type || 1) === 1 && !!String(cipher.login?.decPassword || '').trim()
);
let cancelled = false;
void (async () => {
try {
const [signature, results] = await Promise.all([
computePasswordSignature(loginCiphers),
checkCipherPasswordsExposed(loginCiphers),
]);
if (cancelled) return;
setExposedStatusMap(results);
const previousSignature =
typeof localStorage !== 'undefined'
? String(localStorage.getItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY) || '').trim()
: '';
if (typeof localStorage !== 'undefined') {
localStorage.setItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY, signature);
}
if (hasCompletedAutoExposureCheckRef.current && previousSignature && previousSignature !== signature) {
const count = countVisibleExposed(results, ignoredExposedMap);
if (count > 0) {
props.onNotify('warning', t('txt_exposed_password_check_complete_count', { count }));
}
}
hasCompletedAutoExposureCheckRef.current = true;
} catch {
// Keep exposed-password checks silent in the background.
}
})();
return () => {
cancelled = true;
};
}, [props.ciphers, props.loading]);
const node = listPanelRef.current;
if (!node) return;
const updateSize = () => setListViewportHeight(node.clientHeight || 0);
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(node);
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const onPointerDown = (event: Event) => {
@@ -627,7 +554,6 @@ export default function VaultPage(props: VaultPageProps) {
} else {
if (isDeleted) return false;
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'exposed' && !isVisibleExposed(cipher.id)) return false;
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') {
if (sidebarFilter.folderId === null) {
@@ -663,7 +589,18 @@ export default function VaultPage(props: VaultPageProps) {
});
return next;
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, exposedStatusMap, ignoredExposedMap]);
}, [props.ciphers, sidebarFilter, searchQuery, sortMode]);
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
return sidebarFilter.kind;
}, [sidebarFilter]);
useEffect(() => {
setListScrollTop(0);
listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]);
useEffect(() => {
if (isCreating) return;
@@ -680,8 +617,25 @@ export default function VaultPage(props: VaultPageProps) {
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const selectedCipherExposed = !!(selectedCipher && exposedStatusMap[selectedCipher.id]);
const selectedCipherIgnored = !!(selectedCipher && ignoredExposedMap[selectedCipher.id]);
const virtualRange = useMemo(() => {
if (!filteredCiphers.length) {
return { start: 0, end: 0, padTop: 0, padBottom: 0 };
}
const viewport = Math.max(listViewportHeight, VAULT_LIST_ROW_HEIGHT * 8);
const visibleCount = Math.ceil(viewport / VAULT_LIST_ROW_HEIGHT);
const start = Math.max(0, Math.floor(listScrollTop / VAULT_LIST_ROW_HEIGHT) - VAULT_LIST_OVERSCAN);
const end = Math.min(filteredCiphers.length, start + visibleCount + VAULT_LIST_OVERSCAN * 2);
return {
start,
end,
padTop: start * VAULT_LIST_ROW_HEIGHT,
padBottom: Math.max(0, (filteredCiphers.length - end) * VAULT_LIST_ROW_HEIGHT),
};
}, [filteredCiphers.length, listScrollTop, listViewportHeight]);
const visibleCiphers = useMemo(
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
[filteredCiphers, virtualRange.start, virtualRange.end]
);
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
const selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
@@ -885,26 +839,11 @@ function folderName(id: string | null | undefined): string {
if (isCreating) {
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
const passwordChanged =
nextDraft.type === 1 &&
String(nextDraft.loginPassword || '') !== String(selectedCipher.login?.decPassword || '');
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
if (passwordChanged) {
setExposedStatusMap((prev) => {
const next = { ...prev };
delete next[selectedCipher.id];
return next;
});
setIgnoredExposedMap((prev) => {
const next = { ...prev };
delete next[selectedCipher.id];
return next;
});
}
}
setIsCreating(false);
setIsEditing(false);
@@ -971,15 +910,6 @@ function folderName(id: string | null | undefined): string {
}
}
function toggleIgnoreExposed(cipherId: string): void {
setIgnoredExposedMap((prev) => {
const next = { ...prev };
if (next[cipherId]) delete next[cipherId];
else next[cipherId] = true;
return next;
});
}
async function verifyReprompt(): Promise<void> {
if (!selectedCipher) return;
if (!repromptPassword) {
@@ -1028,6 +958,20 @@ function folderName(id: string | null | undefined): string {
}
}
async function confirmDeleteAllFolders(): Promise<void> {
if (!props.folders.length) return;
setBusy(true);
try {
await props.onBulkDeleteFolders(props.folders.map((folder) => folder.id));
if (sidebarFilter.kind === 'folder') {
setSidebarFilter({ kind: 'all' });
}
setDeleteAllFoldersOpen(false);
} finally {
setBusy(false);
}
}
return (
<>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
@@ -1048,9 +992,6 @@ function folderName(id: string | null | undefined): string {
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'exposed' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'exposed' })}>
<ShieldAlert size={14} className="tree-icon" /> <span className="tree-label">{t('txt_exposed_passwords')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button>
@@ -1078,9 +1019,21 @@ function folderName(id: string | null | undefined): string {
<div className="sidebar-block">
<div className="sidebar-title-row">
<div className="sidebar-title">{t('txt_folders')}</div>
<button type="button" className="folder-add-btn" onClick={() => setCreateFolderOpen(true)}>
<FolderPlus size={14} />
</button>
<div className="folder-title-actions">
<button
type="button"
className="folder-delete-btn"
title={t('txt_delete_all_folders')}
aria-label={t('txt_delete_all_folders')}
disabled={busy || props.folders.length === 0}
onClick={() => setDeleteAllFoldersOpen(true)}
>
<X size={14} />
</button>
<button type="button" className="folder-add-btn" onClick={() => setCreateFolderOpen(true)}>
<FolderPlus size={14} />
</button>
</div>
</div>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'folder', folderId: null })}>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
@@ -1219,8 +1172,14 @@ function folderName(id: string | null | undefined): string {
)}
</div>
<div className="list-panel">
{filteredCiphers.map((cipher) => (
<div
className="list-panel"
ref={listPanelRef}
onScroll={(event) => setListScrollTop((event.currentTarget as HTMLDivElement).scrollTop)}
>
{!!filteredCiphers.length && (
<div style={{ paddingTop: `${virtualRange.padTop}px`, paddingBottom: `${virtualRange.padBottom}px` }}>
{visibleCiphers.map((cipher) => (
<div key={cipher.id} className={`list-item ${selectedCipherId === cipher.id ? 'active' : ''}`}>
<input
type="checkbox"
@@ -1252,13 +1211,14 @@ function folderName(id: string | null | undefined): string {
<div className="list-text">
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
{isVisibleExposed(cipher.id) ? <span className="list-badge danger">{t('txt_exposed_short')}</span> : null}
</span>
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
</div>
</button>
</div>
))}
))}
</div>
)}
{!filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div>
</section>
@@ -1678,25 +1638,6 @@ function folderName(id: string | null | undefined): string {
</button>
</div>
</div>
{selectedCipherExposed && (
<div className="kv-row">
<span className="kv-label">{t('txt_exposed_passwords')}</span>
<div className="kv-main">
<strong className="exposed-status danger">
{selectedCipherIgnored ? t('txt_exposed_ignored') : t('txt_exposed')}
</strong>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
onClick={() => toggleIgnoreExposed(selectedCipher.id)}
>
<X size={14} className="btn-icon" /> {selectedCipherIgnored ? t('txt_unignore') : t('txt_ignore')}
</button>
</div>
</div>
)}
{!!selectedCipher.login.decTotp && (
<div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span>
@@ -2086,6 +2027,17 @@ function folderName(id: string | null | undefined): string {
onCancel={() => setPendingDeleteFolder(null)}
/>
<ConfirmDialog
open={deleteAllFoldersOpen}
title={t('txt_delete_all_folders')}
message={t('txt_delete_all_folders_message')}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => void confirmDeleteAllFolders()}
onCancel={() => setDeleteAllFoldersOpen(false)}
/>
<ConfirmDialog
open={repromptOpen}
title={t('txt_unlock_item')}
+92 -99
View File
@@ -373,6 +373,13 @@ export async function createFolder(
return { id: body.id, name: body.name ?? null };
}
export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
return encryptBw(new TextEncoder().encode(name), enc, mac);
}
export async function deleteFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
folderId: string
@@ -385,6 +392,18 @@ export async function deleteFolder(
if (!resp.ok) throw new Error('Delete folder failed');
}
export async function bulkDeleteFolders(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[]
): Promise<void> {
const resp = await authedFetch('/api/folders/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('Bulk delete folders failed');
}
export async function updateFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
@@ -1010,111 +1029,21 @@ async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac
return { enc: userEnc, mac: userMac, key: null };
}
export async function createCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
async function buildCipherPayload(
session: SessionState,
draft: VaultDraft
): Promise<{ id: string }> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
const type = Number(draft.type || 1);
const payload: Record<string, unknown> = {
type,
favorite: !!draft.favorite,
folderId: asNullable(draft.folderId),
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, enc, mac),
notes: await encryptTextValue(draft.notes, enc, mac),
login: null,
card: null,
identity: null,
secureNote: null,
sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], enc, mac),
};
if (type === 1) {
payload.login = {
username: await encryptTextValue(draft.loginUsername, enc, mac),
password: await encryptTextValue(draft.loginPassword, enc, mac),
totp: await encryptTextValue(draft.loginTotp, enc, mac),
fido2Credentials: await normalizeFido2Credentials(draft.loginFido2Credentials, enc, mac),
uris: await encryptUris(draft.loginUris || [], enc, mac),
};
} else if (type === 3) {
payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, enc, mac),
number: await encryptTextValue(draft.cardNumber, enc, mac),
brand: await encryptTextValue(draft.cardBrand, enc, mac),
expMonth: await encryptTextValue(draft.cardExpMonth, enc, mac),
expYear: await encryptTextValue(draft.cardExpYear, enc, mac),
code: await encryptTextValue(draft.cardCode, enc, mac),
};
} else if (type === 4) {
payload.identity = {
title: await encryptTextValue(draft.identTitle, enc, mac),
firstName: await encryptTextValue(draft.identFirstName, enc, mac),
middleName: await encryptTextValue(draft.identMiddleName, enc, mac),
lastName: await encryptTextValue(draft.identLastName, enc, mac),
username: await encryptTextValue(draft.identUsername, enc, mac),
company: await encryptTextValue(draft.identCompany, enc, mac),
ssn: await encryptTextValue(draft.identSsn, enc, mac),
passportNumber: await encryptTextValue(draft.identPassportNumber, enc, mac),
licenseNumber: await encryptTextValue(draft.identLicenseNumber, enc, mac),
email: await encryptTextValue(draft.identEmail, enc, mac),
phone: await encryptTextValue(draft.identPhone, enc, mac),
address1: await encryptTextValue(draft.identAddress1, enc, mac),
address2: await encryptTextValue(draft.identAddress2, enc, mac),
address3: await encryptTextValue(draft.identAddress3, enc, mac),
city: await encryptTextValue(draft.identCity, enc, mac),
state: await encryptTextValue(draft.identState, enc, mac),
postalCode: await encryptTextValue(draft.identPostalCode, enc, mac),
country: await encryptTextValue(draft.identCountry, enc, mac),
};
} else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, enc, mac);
payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
const resp = await authedFetch('/api/ciphers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
const body = await parseJson<{ id?: string }>(resp);
if (!body?.id) throw new Error('Create item failed');
return { id: body.id };
}
export async function updateCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipher: Cipher,
draft: VaultDraft
): Promise<void> {
draft: VaultDraft,
cipher: Cipher | null
): Promise<Record<string, unknown>> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher.type || 1);
const type = Number(draft.type || cipher?.type || 1);
const payload: Record<string, unknown> = {
id: cipher.id,
type,
key: keys.key,
folderId: asNullable(draft.folderId),
favorite: !!draft.favorite,
folderId: asNullable(draft.folderId),
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
@@ -1126,11 +1055,16 @@ export async function updateCipher(
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
};
if (cipher?.id) {
payload.id = cipher.id;
payload.key = keys.key;
}
if (type === 1) {
const existingFido2 =
cipher.login && Array.isArray((cipher.login as any).fido2Credentials)
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: null;
: draft.loginFido2Credentials;
payload.login = {
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
@@ -1174,13 +1108,48 @@ export async function updateCipher(
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
return payload;
}
export async function buildCipherImportPayload(
session: SessionState,
draft: VaultDraft
): Promise<Record<string, unknown>> {
return buildCipherPayload(session, draft, null);
}
export async function createCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
draft: VaultDraft
): Promise<{ id: string }> {
const payload = await buildCipherPayload(session, draft, null);
const resp = await authedFetch('/api/ciphers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
const body = await parseJson<{ id?: string }>(resp);
if (!body?.id) throw new Error('Create item failed');
return { id: body.id };
}
export async function updateCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipher: Cipher,
draft: VaultDraft
): Promise<void> {
const payload = await buildCipherPayload(session, draft, cipher);
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1197,6 +1166,18 @@ export async function deleteCipher(
if (!resp.ok) throw new Error('Delete item failed');
}
export async function bulkDeleteCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[]
): Promise<void> {
const resp = await authedFetch('/api/ciphers/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('Bulk delete failed');
}
export async function bulkMoveCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[],
@@ -1431,6 +1412,18 @@ export async function deleteSend(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
}
export async function bulkDeleteSends(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[]
): Promise<void> {
const resp = await authedFetch('/api/sends/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('Bulk delete sends failed');
}
async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
const plainPassword = String(password || '').trim();
+8 -16
View File
@@ -141,11 +141,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)",
txt_exposed: "Exposed",
txt_exposed_password_check_complete_count: "{count} exposed password(s) found",
txt_exposed_ignored: "Exposed (Ignored)",
txt_exposed_passwords: "Exposed Passwords",
txt_exposed_short: "Exposed",
txt_expires_at: "Expires At",
txt_expires_at_value: "Expires at: {value}",
txt_expiry: "Expiry",
@@ -256,7 +251,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_no: "No",
txt_no_devices_found: "No devices found.",
txt_no_folder: "No Folder",
txt_no_exposed_passwords_found: "No exposed passwords found",
txt_no_items: "No items",
txt_no_username: "(No username)",
txt_no_verification_codes: "No verification codes",
@@ -300,7 +294,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_regenerate: "Regenerate",
txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.",
txt_remove: "Remove",
txt_ignore: "Ignore",
txt_remove_device: "Remove device",
txt_remove_device_2: "Remove Device",
txt_remove_all_devices: "Remove all devices",
@@ -392,7 +385,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_unlock_item: "Unlock Item",
txt_unlock_send: "Unlock Send",
txt_unlock_vault: "Unlock Vault",
txt_unignore: "Unignore",
txt_unlocked: "Unlocked",
txt_all_devices_removed: "All devices removed",
txt_remove_device_failed: "Failed to remove device",
@@ -454,7 +446,6 @@ const zhCNOverrides: Record<string, string> = {
txt_back_to_login: '返回登录',
txt_unlock: '解锁',
txt_unlock_vault: '解锁密码库',
txt_unignore: '取消忽略',
txt_master_password: '主密码',
txt_email: '邮箱',
txt_name: '名称',
@@ -481,7 +472,6 @@ const zhCNOverrides: Record<string, string> = {
txt_copy: '复制',
txt_code_copied: '验证码已复制',
txt_copy_link: '复制链接',
txt_ignore: '忽略',
txt_select_all: '全选',
txt_delete_selected: '删除所选',
txt_all_items: '所有项目',
@@ -490,7 +480,6 @@ const zhCNOverrides: Record<string, string> = {
txt_folder: '文件夹',
txt_folders: '文件夹',
txt_no_folder: '无文件夹',
txt_no_exposed_passwords_found: '未发现已泄露密码',
txt_no_items: '没有项目',
txt_no_username: '无用户名',
txt_no_verification_codes: '没有验证码',
@@ -498,11 +487,6 @@ const zhCNOverrides: Record<string, string> = {
txt_select_an_item: '请选择一个项目',
txt_login: '登录',
txt_card: '银行卡',
txt_exposed: '已泄露',
txt_exposed_password_check_complete_count: '发现 {count} 个已泄露密码',
txt_exposed_ignored: '已泄露(已忽略)',
txt_exposed_passwords: '是否泄露',
txt_exposed_short: '泄露',
txt_identity: '身份',
txt_note: '笔记',
txt_secure_note: '安全笔记',
@@ -883,9 +867,13 @@ messages.en.txt_new_type_header = 'New {type}';
messages.en.txt_edit_type_header = 'Edit {type}';
messages.en.txt_delete_folder = 'Delete Folder';
messages.en.txt_delete_folder_message = 'Delete folder "{name}"? Items inside will move to No Folder.';
messages.en.txt_delete_all_folders = 'Delete All Folders';
messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.';
messages.en.txt_folder_not_found = 'Folder not found';
messages.en.txt_folder_deleted = 'Folder deleted';
messages.en.txt_folders_deleted = 'Folders deleted';
messages.en.txt_delete_folder_failed = 'Delete folder failed';
messages.en.txt_delete_all_folders_failed = 'Delete all folders failed';
messages.en.txt_other = 'Other';
messages.en.txt_vault_key_unavailable = 'Vault key unavailable. Please unlock vault and try again.';
messages.en.txt_vault_not_ready = 'Vault is not ready yet';
@@ -945,9 +933,13 @@ zhCNOverrides.txt_new_type_header = '新建{type}';
zhCNOverrides.txt_edit_type_header = '编辑{type}';
zhCNOverrides.txt_delete_folder = '删除文件夹';
zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
zhCNOverrides.txt_other = '其他';
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁密码库后重试。';
zhCNOverrides.txt_vault_not_ready = '密码库数据尚未就绪';
-127
View File
@@ -1,127 +0,0 @@
import type { Cipher } from './types';
const HIBP_RANGE_ENDPOINT = 'https://api.pwnedpasswords.com/range/';
const RANGE_CACHE_PREFIX = 'nodewarden.password-breach.range.v1.';
const RANGE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const inMemoryRangeCache = new Map<string, { expiresAt: number; suffixes: Set<string> }>();
const inflightRangeRequests = new Map<string, Promise<Set<string>>>();
function normalizeHashHex(value: string): string {
return String(value || '').trim().toUpperCase();
}
async function sha1Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-1', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function readCachedSuffixes(prefix: string): Set<string> | null {
const now = Date.now();
const memory = inMemoryRangeCache.get(prefix);
if (memory && memory.expiresAt > now) return new Set(memory.suffixes);
if (memory) inMemoryRangeCache.delete(prefix);
if (typeof sessionStorage === 'undefined') return null;
const raw = sessionStorage.getItem(`${RANGE_CACHE_PREFIX}${prefix}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { expiresAt?: number; suffixes?: string[] };
if (!parsed.expiresAt || parsed.expiresAt <= now || !Array.isArray(parsed.suffixes)) {
sessionStorage.removeItem(`${RANGE_CACHE_PREFIX}${prefix}`);
return null;
}
const suffixes = new Set(parsed.suffixes.map(normalizeHashHex));
inMemoryRangeCache.set(prefix, { expiresAt: parsed.expiresAt, suffixes });
return new Set(suffixes);
} catch {
sessionStorage.removeItem(`${RANGE_CACHE_PREFIX}${prefix}`);
return null;
}
}
function writeCachedSuffixes(prefix: string, suffixes: Set<string>): void {
const expiresAt = Date.now() + RANGE_CACHE_TTL_MS;
inMemoryRangeCache.set(prefix, { expiresAt, suffixes: new Set(suffixes) });
if (typeof sessionStorage === 'undefined') return;
sessionStorage.setItem(
`${RANGE_CACHE_PREFIX}${prefix}`,
JSON.stringify({ expiresAt, suffixes: Array.from(suffixes) })
);
}
async function getRangeSuffixes(prefix: string): Promise<Set<string>> {
const normalizedPrefix = normalizeHashHex(prefix).slice(0, 5);
if (!/^[A-F0-9]{5}$/.test(normalizedPrefix)) throw new Error('Invalid password hash prefix');
const cached = readCachedSuffixes(normalizedPrefix);
if (cached) return cached;
const inflight = inflightRangeRequests.get(normalizedPrefix);
if (inflight) return inflight;
const request = (async () => {
const response = await fetch(`${HIBP_RANGE_ENDPOINT}${normalizedPrefix}`, {
method: 'GET',
headers: {
Accept: 'text/plain',
'Add-Padding': 'true',
},
cache: 'no-store',
});
if (!response.ok) throw new Error('Failed to check exposed passwords');
const body = await response.text();
const suffixes = new Set<string>();
for (const line of body.split(/\r?\n/)) {
const [suffix] = line.split(':', 1);
const normalizedSuffix = normalizeHashHex(suffix || '');
if (/^[A-F0-9]{35}$/.test(normalizedSuffix)) suffixes.add(normalizedSuffix);
}
writeCachedSuffixes(normalizedPrefix, suffixes);
return suffixes;
})();
inflightRangeRequests.set(normalizedPrefix, request);
try {
return await request;
} finally {
inflightRangeRequests.delete(normalizedPrefix);
}
}
export async function checkCipherPasswordsExposed(ciphers: Cipher[]): Promise<Record<string, boolean>> {
const loginCiphers = ciphers.filter((cipher) => {
const password = String(cipher.login?.decPassword || '').trim();
return cipher.type === 1 && !!cipher.id && !!password;
});
const uniquePasswords = new Map<string, string>();
for (const cipher of loginCiphers) {
const password = String(cipher.login?.decPassword || '');
if (!uniquePasswords.has(password)) {
uniquePasswords.set(password, await sha1Hex(password));
}
}
const prefixes = Array.from(new Set(Array.from(uniquePasswords.values(), (hash) => hash.slice(0, 5))));
const rangeMap = new Map<string, Set<string>>();
await Promise.all(
prefixes.map(async (prefix) => {
rangeMap.set(prefix, await getRangeSuffixes(prefix));
})
);
const results: Record<string, boolean> = {};
for (const cipher of loginCiphers) {
const password = String(cipher.login?.decPassword || '');
const hash = uniquePasswords.get(password);
if (!hash) continue;
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
results[cipher.id] = !!rangeMap.get(prefix)?.has(suffix);
}
return results;
}
+10 -3
View File
@@ -601,6 +601,12 @@ input[type='file'].input::file-selector-button:hover {
margin-bottom: 0;
}
.folder-title-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.folder-add-btn {
border: none;
background: transparent;
@@ -808,9 +814,11 @@ input[type='file'].input::file-selector-button:hover {
border-radius: 10px;
padding: 10px 12px;
display: flex;
align-items: flex-start;
align-items: center;
gap: 10px;
margin-bottom: 8px;
min-height: 66px;
box-sizing: border-box;
}
.list-item:hover {
@@ -836,7 +844,7 @@ input[type='file'].input::file-selector-button:hover {
background: transparent;
padding: 0;
display: flex;
align-items: flex-start;
align-items: center;
gap: 10px;
text-align: left;
cursor: pointer;
@@ -848,7 +856,6 @@ input[type='file'].input::file-selector-button:hover {
display: grid;
place-items: center;
flex-shrink: 0;
margin-top: 1px;
}
.list-icon {