feat(ciphers): add bulk restore and permanent delete functionality for ciphers

style: enhance list count display in VaultPage and styles
fix(i18n): add translations for bulk restore and permanent delete messages
This commit is contained in:
shuaiplus
2026-03-12 01:37:33 +08:00
parent ad764a9c5b
commit 3eb517a92f
8 changed files with 415 additions and 74 deletions
+55
View File
@@ -541,3 +541,58 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
// POST /api/ciphers/restore - Bulk restore
export async function handleBulkRestoreCiphers(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.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
// POST /api/ciphers/delete-permanent - Bulk permanent delete
export async function handleBulkPermanentDeleteCiphers(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 ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!ids.length) {
return new Response(null, { status: 204 });
}
for (const id of ids) {
await deleteAllAttachmentsForCipher(env, id);
}
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
+10
View File
@@ -35,6 +35,8 @@ import {
handlePartialUpdateCipher, handlePartialUpdateCipher,
handleBulkMoveCiphers, handleBulkMoveCiphers,
handleBulkDeleteCiphers, handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers,
} from './handlers/ciphers'; } from './handlers/ciphers';
// Folder handlers // Folder handlers
@@ -607,6 +609,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleBulkDeleteCiphers(request, env, userId); return handleBulkDeleteCiphers(request, env, userId);
} }
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
return handleBulkPermanentDeleteCiphers(request, env, userId);
}
if (path === '/api/ciphers/restore' && method === 'POST') {
return handleBulkRestoreCiphers(request, env, userId);
}
// Bulk cipher operations (only move is allowed) // Bulk cipher operations (only move is allowed)
if (path === '/api/ciphers/move') { if (path === '/api/ciphers/move') {
if (method === 'POST' || method === 'PUT') { if (method === 'POST' || method === 'PUT') {
+92 -22
View File
@@ -109,6 +109,7 @@ export class StorageService {
private static schemaVerified = false; private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0; private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
@@ -126,6 +127,13 @@ export class StorageService {
return stmt.bind(...values.map(v => v === undefined ? null : v)); return stmt.bind(...values.map(v => v === undefined ? null : v));
} }
private sqlChunkSize(fixedBindCount: number): number {
return Math.max(
1,
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount)
);
}
private async sha256Hex(input: string): Promise<string> { private async sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input); const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes); const digest = await crypto.subtle.digest('SHA-256', bytes);
@@ -479,7 +487,7 @@ export class StorageService {
deletedAt: now, deletedAt: now,
updatedAt: now, updatedAt: now,
}); });
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); const chunkSize = this.sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -497,6 +505,53 @@ export class StorageService {
return this.updateRevisionDate(userId); return this.updateRevisionDate(userId);
} }
async bulkRestoreCiphers(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: null,
updatedAt: now,
});
const chunkSize = this.sqlChunkSize(3);
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 = NULL, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.run();
}
return this.updateRevisionDate(userId);
}
async bulkDeleteCiphers(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 = this.sqlChunkSize(1);
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 ciphers WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
.run();
}
return this.updateRevisionDate(userId);
}
async getAllCiphers(userId: string): Promise<Cipher[]> { 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 }>(); 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 => { return (res.results || []).flatMap(r => {
@@ -523,13 +578,22 @@ export class StorageService {
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> { async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
// D1 doesn't support binding arrays directly; build placeholders. const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
const placeholders = ids.map(() => '?').join(','); if (!uniqueIds.length) return [];
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); const chunkSize = this.sqlChunkSize(1);
const res = await stmt.bind(userId, ...ids).all<{ data: string }>(); const out: Cipher[] = [];
return (res.results || []).flatMap(r => { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; } const chunk = uniqueIds.slice(i, i + chunkSize);
}); const placeholders = chunk.map(() => '?').join(',');
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
const res = await stmt.bind(userId, ...chunk).all<{ data: string }>();
out.push(
...(res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
})
);
}
return out;
} }
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<string | null> { async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<string | null> {
@@ -540,7 +604,7 @@ export class StorageService {
folderId, folderId,
updatedAt: now, updatedAt: now,
}); });
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); const chunkSize = this.sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -594,7 +658,7 @@ export class StorageService {
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)));
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); const chunkSize = this.sqlChunkSize(1);
const now = new Date().toISOString(); const now = new Date().toISOString();
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
@@ -728,7 +792,7 @@ export class StorageService {
if (cipherIds.length === 0) return grouped; if (cipherIds.length === 0) return grouped;
const uniqueCipherIds = [...new Set(cipherIds)]; const uniqueCipherIds = [...new Set(cipherIds)];
const chunkSize = LIMITS.performance.bulkMoveChunkSize; const chunkSize = this.sqlChunkSize(0);
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) { for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
const chunk = uniqueCipherIds.slice(i, i + chunkSize); const chunk = uniqueCipherIds.slice(i, i + chunkSize);
@@ -996,23 +1060,29 @@ export class StorageService {
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 = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return []; if (!uniqueIds.length) return [];
const placeholders = uniqueIds.map(() => '?').join(','); const chunkSize = this.sqlChunkSize(1);
const res = await this.db const out: Send[] = [];
.prepare( for (let i = 0; i < uniqueIds.length; i += chunkSize) {
`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 const chunk = uniqueIds.slice(i, i + chunkSize);
FROM sends const placeholders = chunk.map(() => '?').join(',');
WHERE user_id = ? AND id IN (${placeholders})` const res = await this.db
) .prepare(
.bind(userId, ...uniqueIds) `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
.all<any>(); FROM sends
return (res.results || []).map((row) => this.mapSendRow(row)); WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(userId, ...chunk)
.all<any>();
out.push(...(res.results || []).map((row) => this.mapSendRow(row)));
}
return out;
} }
async bulkDeleteSends(ids: string[], userId: string): Promise<string | null> { async bulkDeleteSends(ids: string[], userId: string): 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 = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); const chunkSize = this.sqlChunkSize(1);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
+26
View File
@@ -26,6 +26,8 @@ import {
deleteCipherAttachment, deleteCipherAttachment,
deleteFolder, deleteFolder,
bulkDeleteCiphers, bulkDeleteCiphers,
bulkPermanentDeleteCiphers,
bulkRestoreCiphers,
bulkDeleteSends, bulkDeleteSends,
createCipher, createCipher,
createAuthedFetch, createAuthedFetch,
@@ -1345,6 +1347,28 @@ export default function App() {
} }
} }
async function bulkRestoreVaultItems(ids: string[]) {
try {
await bulkRestoreCiphers(authedFetch, ids);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', t('txt_restored_selected_items'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
throw error;
}
}
async function bulkPermanentDeleteVaultItems(ids: string[]) {
try {
await bulkPermanentDeleteCiphers(authedFetch, ids);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', t('txt_deleted_selected_items_permanently'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
throw error;
}
}
async function bulkDeleteFoldersAction(ids: string[]) { async function bulkDeleteFoldersAction(ids: string[]) {
const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!folderIds.length) return; if (!folderIds.length) return;
@@ -2056,6 +2080,8 @@ export default function App() {
onUpdate={updateVaultItem} onUpdate={updateVaultItem}
onDelete={deleteVaultItem} onDelete={deleteVaultItem}
onBulkDelete={bulkDeleteVaultItems} onBulkDelete={bulkDeleteVaultItems}
onBulkPermanentDelete={bulkPermanentDeleteVaultItems}
onBulkRestore={bulkRestoreVaultItems}
onBulkMove={bulkMoveVaultItems} onBulkMove={bulkMoveVaultItems}
onVerifyMasterPassword={verifyMasterPasswordAction} onVerifyMasterPassword={verifyMasterPasswordAction}
onNotify={pushToast} onNotify={pushToast}
+38 -5
View File
@@ -46,6 +46,8 @@ interface VaultPageProps {
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>;
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (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;
@@ -679,6 +681,7 @@ export default function VaultPage(props: VaultPageProps) {
() => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0), () => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0),
[selectedMap] [selectedMap]
); );
const totalCipherCount = filteredCiphers.length;
function folderName(id: string | null | undefined): string { function folderName(id: string | null | undefined): string {
if (!id) return t('txt_no_folder'); if (!id) return t('txt_no_folder');
@@ -877,7 +880,11 @@ function folderName(id: string | null | undefined): string {
if (!ids.length) return; if (!ids.length) return;
setBusy(true); setBusy(true);
try { try {
await props.onBulkDelete(ids); if (sidebarFilter.kind === 'trash') {
await props.onBulkPermanentDelete(ids);
} else {
await props.onBulkDelete(ids);
}
setSelectedMap({}); setSelectedMap({});
setBulkDeleteOpen(false); setBulkDeleteOpen(false);
} finally { } finally {
@@ -958,6 +965,20 @@ function folderName(id: string | null | undefined): string {
} }
} }
async function confirmBulkRestore(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkRestore(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);
@@ -1111,13 +1132,16 @@ function folderName(id: string | null | undefined): string {
</div> </div>
)} )}
</div> </div>
<div className="list-count" title={t('txt_total_items_count', { count: totalCipherCount })}>
{t('txt_total_items_count', { count: totalCipherCount })}
</div>
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void syncVault()}> <button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void syncVault()}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')} <RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button> </button>
</div> </div>
<div className="toolbar actions"> <div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => setBulkDeleteOpen(true)}> <button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => setBulkDeleteOpen(true)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')} <Trash2 size={14} className="btn-icon" /> {sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button> </button>
<button <button
type="button" type="button"
@@ -1152,7 +1176,12 @@ function folderName(id: string | null | undefined): string {
</div> </div>
)} )}
</div> </div>
{selectedCount > 0 && ( {selectedCount > 0 && sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => void confirmBulkRestore()}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{selectedCount > 0 && sidebarFilter.kind !== 'trash' && (
<button <button
type="button" type="button"
className="btn btn-secondary small" className="btn btn-secondary small"
@@ -1969,8 +1998,12 @@ function folderName(id: string | null | undefined): string {
<ConfirmDialog <ConfirmDialog
open={bulkDeleteOpen} open={bulkDeleteOpen}
title={t('txt_delete_selected_items')} title={sidebarFilter.kind === 'trash' ? t('txt_delete_selected_items_permanently') : t('txt_delete_selected_items')}
message={t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: selectedCount })} message={
sidebarFilter.kind === 'trash'
? t('txt_are_you_sure_you_want_to_delete_count_selected_items_permanently', { count: selectedCount })
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: selectedCount })
}
danger danger
onConfirm={() => void confirmBulkDelete()} onConfirm={() => void confirmBulkDelete()}
onCancel={() => setBulkDeleteOpen(false)} onCancel={() => setBulkDeleteOpen(false)}
+164 -47
View File
@@ -22,9 +22,19 @@ import type {
const SESSION_KEY = 'nodewarden.web.session.v4'; const SESSION_KEY = 'nodewarden.web.session.v4';
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1'; const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
const BULK_API_CHUNK_SIZE = 200;
type SessionSetter = (next: SessionState | null) => void; type SessionSetter = (next: SessionState | null) => void;
function chunkArray<T>(items: T[], size: number): T[][] {
if (items.length <= size) return [items];
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
export function loadSession(): SessionState | null { export function loadSession(): SessionState | null {
try { try {
const raw = localStorage.getItem(SESSION_KEY); const raw = localStorage.getItem(SESSION_KEY);
@@ -396,12 +406,15 @@ export async function bulkDeleteFolders(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>, authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[] ids: string[]
): Promise<void> { ): Promise<void> {
const resp = await authedFetch('/api/folders/delete', { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
method: 'POST', for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
headers: { 'Content-Type': 'application/json' }, const resp = await authedFetch('/api/folders/delete', {
body: JSON.stringify({ ids }), method: 'POST',
}); headers: { 'Content-Type': 'application/json' },
if (!resp.ok) throw new Error('Bulk delete folders failed'); body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk delete folders failed');
}
} }
export async function updateFolder( export async function updateFolder(
@@ -450,31 +463,96 @@ export async function importCiphers(
): Promise<ImportedCipherMapEntry[] | null> { ): Promise<ImportedCipherMapEntry[] | null> {
const returnCipherMap = !!options?.returnCipherMap; const returnCipherMap = !!options?.returnCipherMap;
const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import'; const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import';
const resp = await authedFetch(url, { const totalItems = (payload.folders?.length || 0) + (payload.ciphers?.length || 0);
method: 'POST', const responses: ImportedCipherMapEntry[] = [];
headers: { 'Content-Type': 'application/json' }, const folderChunkSize = Math.min(BULK_API_CHUNK_SIZE, Math.max(0, BULK_API_CHUNK_SIZE - 1));
body: JSON.stringify(payload),
}); if (totalItems <= BULK_API_CHUNK_SIZE || payload.folders.length > folderChunkSize) {
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed')); const resp = await authedFetch(url, {
if (!returnCipherMap) return null; method: 'POST',
const body = headers: { 'Content-Type': 'application/json' },
(await parseJson<{ body: JSON.stringify(payload),
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
}>(resp)) || {};
if (!Array.isArray(body.cipherMap)) return [];
const out: ImportedCipherMapEntry[] = [];
for (const row of body.cipherMap) {
const index = Number(row?.index);
const id = String(row?.id || '').trim();
if (!Number.isFinite(index) || !id) continue;
const sourceRaw = String(row?.sourceId || '').trim();
out.push({
index,
id,
sourceId: sourceRaw || null,
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
if (!returnCipherMap) return null;
const body =
(await parseJson<{
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
}>(resp)) || {};
if (!Array.isArray(body.cipherMap)) return [];
for (const row of body.cipherMap) {
const index = Number(row?.index);
const id = String(row?.id || '').trim();
if (!Number.isFinite(index) || !id) continue;
const sourceRaw = String(row?.sourceId || '').trim();
responses.push({
index,
id,
sourceId: sourceRaw || null,
});
}
return responses;
} }
return out;
const folders = payload.folders || [];
const relationshipsByCipher = new Map<number, number | null>();
for (const relation of payload.folderRelationships || []) {
relationshipsByCipher.set(Number(relation.key), Number(relation.value));
}
for (const cipherChunkStart of Array.from({ length: Math.ceil(payload.ciphers.length / BULK_API_CHUNK_SIZE) }, (_, i) => i * BULK_API_CHUNK_SIZE)) {
const cipherChunk = payload.ciphers.slice(cipherChunkStart, cipherChunkStart + BULK_API_CHUNK_SIZE);
const usedFolderIndices = Array.from(
new Set(
cipherChunk
.map((_, localIndex) => relationshipsByCipher.get(cipherChunkStart + localIndex))
.filter((value): value is number => Number.isFinite(value as number) && (value as number) >= 0)
)
);
const folderIndexMap = new Map<number, number>();
const chunkFolders = usedFolderIndices.map((folderIndex, localIndex) => {
folderIndexMap.set(folderIndex, localIndex);
return folders[folderIndex];
});
const chunkRelationships = cipherChunk
.map((_, localIndex) => {
const originalCipherIndex = cipherChunkStart + localIndex;
const originalFolderIndex = relationshipsByCipher.get(originalCipherIndex);
if (!Number.isFinite(originalFolderIndex as number)) return null;
const localFolderIndex = folderIndexMap.get(Number(originalFolderIndex));
if (!Number.isFinite(localFolderIndex as number)) return null;
return { key: localIndex, value: Number(localFolderIndex) };
})
.filter((value): value is { key: number; value: number } => !!value);
const resp = await authedFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ciphers: cipherChunk,
folders: chunkFolders,
folderRelationships: chunkRelationships,
}),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
if (!returnCipherMap) continue;
const body =
(await parseJson<{
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
}>(resp)) || {};
for (const row of body.cipherMap || []) {
const localIndex = Number(row?.index);
const id = String(row?.id || '').trim();
if (!Number.isFinite(localIndex) || !id) continue;
const sourceRaw = String(row?.sourceId || '').trim();
responses.push({
index: cipherChunkStart + localIndex,
id,
sourceId: sourceRaw || null,
});
}
}
return returnCipherMap ? responses : null;
} }
export interface AttachmentDownloadInfo { export interface AttachmentDownloadInfo {
@@ -1170,12 +1248,45 @@ export async function bulkDeleteCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>, authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[] ids: string[]
): Promise<void> { ): Promise<void> {
const resp = await authedFetch('/api/ciphers/delete', { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
method: 'POST', for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
headers: { 'Content-Type': 'application/json' }, const resp = await authedFetch('/api/ciphers/delete', {
body: JSON.stringify({ ids }), method: 'POST',
}); headers: { 'Content-Type': 'application/json' },
if (!resp.ok) throw new Error('Bulk delete failed'); body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk delete failed');
}
}
export async function bulkPermanentDeleteCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
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/delete-permanent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk permanent delete failed');
}
}
export async function bulkRestoreCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
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/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk restore failed');
}
} }
export async function bulkMoveCiphers( export async function bulkMoveCiphers(
@@ -1183,12 +1294,15 @@ export async function bulkMoveCiphers(
ids: string[], ids: string[],
folderId: string | null folderId: string | null
): Promise<void> { ): Promise<void> {
const resp = await authedFetch('/api/ciphers/move', { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
method: 'POST', for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
headers: { 'Content-Type': 'application/json' }, const resp = await authedFetch('/api/ciphers/move', {
body: JSON.stringify({ ids, folderId }), method: 'POST',
}); headers: { 'Content-Type': 'application/json' },
if (!resp.ok) throw new Error('Bulk move failed'); body: JSON.stringify({ ids: chunk, folderId }),
});
if (!resp.ok) throw new Error('Bulk move failed');
}
} }
function toIsoDateFromDays(value: string, required: boolean): string | null { function toIsoDateFromDays(value: string, required: boolean): string | null {
@@ -1416,12 +1530,15 @@ export async function bulkDeleteSends(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>, authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[] ids: string[]
): Promise<void> { ): Promise<void> {
const resp = await authedFetch('/api/sends/delete', { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
method: 'POST', for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
headers: { 'Content-Type': 'application/json' }, const resp = await authedFetch('/api/sends/delete', {
body: JSON.stringify({ ids }), method: 'POST',
}); headers: { 'Content-Type': 'application/json' },
if (!resp.ok) throw new Error('Bulk delete sends failed'); body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk delete sends failed');
}
} }
async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> { async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> {
+17
View File
@@ -50,6 +50,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_all_sends: "All Sends", txt_all_sends: "All Sends",
txt_android: "Android", txt_android: "Android",
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?", txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?", txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?", txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
txt_authenticator_key: "Authenticator Key", txt_authenticator_key: "Authenticator Key",
@@ -61,6 +62,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_boolean: "Boolean", txt_boolean: "Boolean",
txt_brand: "Brand", txt_brand: "Brand",
txt_bulk_delete_failed: "Bulk delete failed", txt_bulk_delete_failed: "Bulk delete failed",
txt_bulk_permanent_delete_failed: "Bulk permanent delete failed",
txt_bulk_restore_failed: "Bulk restore failed",
txt_bulk_delete_sends_failed: "Bulk delete sends failed", txt_bulk_delete_sends_failed: "Bulk delete sends failed",
txt_bulk_move_failed: "Bulk move failed", txt_bulk_move_failed: "Bulk move failed",
txt_cancel: "Cancel", txt_cancel: "Cancel",
@@ -106,12 +109,16 @@ const messages: Record<Locale, Record<string, string>> = {
txt_delete_all_invites: "Delete all invites", txt_delete_all_invites: "Delete all invites",
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_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_send_failed: "Delete send failed", txt_delete_send_failed: "Delete send failed",
txt_delete_this_user_and_all_user_data: "Delete this user and all user data?", txt_delete_this_user_and_all_user_data: "Delete this user and all user data?",
txt_delete_user: "Delete user", txt_delete_user: "Delete user",
txt_deleted_selected_items: "Deleted selected items", txt_deleted_selected_items: "Deleted selected items",
txt_deleted_selected_items_permanently: "Permanently deleted selected items",
txt_restored_selected_items: "Restored selected items",
txt_deleted_selected_sends: "Deleted selected sends", txt_deleted_selected_sends: "Deleted selected sends",
txt_deletion_date: "Deletion Date", txt_deletion_date: "Deletion Date",
txt_deletion_days: "Deletion Days", txt_deletion_days: "Deletion Days",
@@ -302,6 +309,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?", txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
txt_remove_device_and_sign_out_name: "Remove device \"{name}\", clear its trust, and sign it out?", txt_remove_device_and_sign_out_name: "Remove device \"{name}\", clear its trust, and sign it out?",
txt_reveal: "Reveal", txt_reveal: "Reveal",
txt_restore: "Restore",
txt_revoke: "Revoke", txt_revoke: "Revoke",
txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?", txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?",
txt_revoke_30_day_totp_trust_from_all_devices: "Revoke 30-day TOTP trust from all devices?", txt_revoke_30_day_totp_trust_from_all_devices: "Revoke 30-day TOTP trust from all devices?",
@@ -361,6 +369,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_totp_disabled: "TOTP disabled", txt_totp_disabled: "TOTP disabled",
txt_totp_enabled: "TOTP enabled", txt_totp_enabled: "TOTP enabled",
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.", txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
txt_total_items_count: "{count} items",
txt_totp_secret: "TOTP Secret", txt_totp_secret: "TOTP Secret",
txt_totp_verify_failed: "TOTP verify failed", txt_totp_verify_failed: "TOTP verify failed",
txt_passkey: "Passkey", txt_passkey: "Passkey",
@@ -507,6 +516,7 @@ const zhCNOverrides: Record<string, string> = {
txt_open: '打开', txt_open: '打开',
txt_hide: '隐藏', txt_hide: '隐藏',
txt_reveal: '显示', txt_reveal: '显示',
txt_restore: '恢复',
txt_favorite: '收藏', txt_favorite: '收藏',
txt_field: '字段', txt_field: '字段',
txt_field_type: '字段类型', txt_field_type: '字段类型',
@@ -640,10 +650,13 @@ const zhCNOverrides: Record<string, string> = {
txt_all_sends: '所有发送', txt_all_sends: '所有发送',
txt_android: '安卓', txt_android: '安卓',
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?', txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
txt_authenticator_key: '验证器密钥', txt_authenticator_key: '验证器密钥',
txt_brand: '品牌', txt_brand: '品牌',
txt_bulk_delete_failed: '批量删除失败', txt_bulk_delete_failed: '批量删除失败',
txt_bulk_permanent_delete_failed: '批量永久删除失败',
txt_bulk_restore_failed: '批量恢复失败',
txt_bulk_delete_sends_failed: '批量删除发送失败', txt_bulk_delete_sends_failed: '批量删除发送失败',
txt_bulk_move_failed: '批量移动失败', txt_bulk_move_failed: '批量移动失败',
txt_cardholder_name: '持卡人姓名', txt_cardholder_name: '持卡人姓名',
@@ -664,10 +677,13 @@ const zhCNOverrides: Record<string, string> = {
txt_delete_all_invite_codes_active_inactive: '删除所有邀请码(有效/无效)?', txt_delete_all_invite_codes_active_inactive: '删除所有邀请码(有效/无效)?',
txt_delete_all_invites: '删除所有邀请码', txt_delete_all_invites: '删除所有邀请码',
txt_delete_item_failed: '删除项目失败', txt_delete_item_failed: '删除项目失败',
txt_delete_permanently: '永久删除',
txt_delete_send_failed: '删除发送失败', txt_delete_send_failed: '删除发送失败',
txt_delete_this_user_and_all_user_data: '删除此用户及其所有数据?', txt_delete_this_user_and_all_user_data: '删除此用户及其所有数据?',
txt_delete_user: '删除用户', txt_delete_user: '删除用户',
txt_deleted_selected_items: '已删除所选项目', txt_deleted_selected_items: '已删除所选项目',
txt_deleted_selected_items_permanently: '已永久删除所选项目',
txt_restored_selected_items: '已恢复所选项目',
txt_deleted_selected_sends: '已删除所选发送', txt_deleted_selected_sends: '已删除所选发送',
txt_device_authorization_revoked: '设备信任已撤销', txt_device_authorization_revoked: '设备信任已撤销',
txt_device_removed: '设备已移除', txt_device_removed: '设备已移除',
@@ -766,6 +782,7 @@ const zhCNOverrides: Record<string, string> = {
txt_totp_disabled: 'TOTP 已禁用', txt_totp_disabled: 'TOTP 已禁用',
txt_totp_enabled: 'TOTP 已启用', txt_totp_enabled: 'TOTP 已启用',
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。', txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
txt_total_items_count: '共 {count} 项',
txt_totp_verify_failed: 'TOTP 验证失败', txt_totp_verify_failed: 'TOTP 验证失败',
txt_trust_this_device_for_30_days: '信任此设备 30 天', txt_trust_this_device_for_30_days: '信任此设备 30 天',
txt_type_type: '类型 {type}', txt_type_type: '类型 {type}',
+13
View File
@@ -733,6 +733,13 @@ input[type='file'].input::file-selector-button:hover {
white-space: nowrap; white-space: nowrap;
} }
.list-count {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.list-icon-btn { .list-icon-btn {
white-space: nowrap; white-space: nowrap;
} }
@@ -2195,6 +2202,12 @@ input[type='file'].input::file-selector-button:hover {
gap: 8px; gap: 8px;
} }
.list-count {
order: 10;
width: 100%;
font-size: 12px;
}
.list-head .search-input { .list-head .search-input {
height: 42px; height: 42px;
border-radius: 14px; border-radius: 14px;