refactor: Remove unused APIs and data structures, optimize loading state component styles

This commit is contained in:
shuaiplus
2026-04-28 23:01:23 +08:00
parent 1b0386bf78
commit 69b98f9e67
18 changed files with 221 additions and 258 deletions
-8
View File
@@ -182,14 +182,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS api_rate_limits (
identifier TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
jti TEXT PRIMARY KEY, jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
-3
View File
@@ -426,9 +426,6 @@ export async function handleDeleteAttachment(
// Delete attachment metadata // Delete attachment metadata
await storage.deleteAttachment(attachmentId); await storage.deleteAttachment(attachmentId);
// Remove attachment from cipher
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
-5
View File
@@ -119,11 +119,6 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
} }
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
void cipherId;
void attachmentId;
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> { export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
} }
-5
View File
@@ -94,11 +94,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
'PRIMARY KEY (identifier, window_start))',
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
-5
View File
@@ -57,7 +57,6 @@ import {
getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId, getAttachmentsByUserId as listStoredAttachmentsByUserId,
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
saveAttachment as saveStoredAttachment, saveAttachment as saveStoredAttachment,
updateCipherRevisionDate as updateStoredCipherRevisionDate, updateCipherRevisionDate as updateStoredCipherRevisionDate,
} from './storage-attachment-repo'; } from './storage-attachment-repo';
@@ -389,10 +388,6 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
} }
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId); await deleteStoredAttachmentsByCipher(this.db, cipherId);
} }
+5 -38
View File
@@ -23,13 +23,8 @@ import {
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import { getSends } from '@/lib/api/send'; import { getSends } from '@/lib/api/send';
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import {
repairCipherAttachmentMetadata,
updateFolder,
} from '@/lib/api/vault';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { import {
looksLikeCipherString,
parseSignalRTextFrames, parseSignalRTextFrames,
readInviteCodeFromUrl, readInviteCodeFromUrl,
} from '@/lib/app-support'; } from '@/lib/app-support';
@@ -170,7 +165,6 @@ export default function App() {
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null); const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false); const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
@@ -850,9 +844,6 @@ export default function App() {
setDecryptedFolders(result.folders); setDecryptedFolders(result.folders);
setDecryptedCiphers(result.ciphers); setDecryptedCiphers(result.ciphers);
setVaultInitialDecryptDone(true); setVaultInitialDecryptDone(true);
for (const repair of result.attachmentRepairs) {
void repairCipherAttachmentMetadata(authedFetch, repair.cipherId, repair.attachmentId, repair.metadata);
}
} catch (error) { } catch (error) {
if (!active) return; if (!active) return;
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
@@ -862,7 +853,7 @@ export default function App() {
return () => { return () => {
active = false; active = false;
}; };
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers, authedFetch]); }, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
useEffect(() => { useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey) { if (!session?.symEncKey || !session?.symMacKey) {
@@ -904,31 +895,6 @@ export default function App() {
}; };
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]); }, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey || !encryptedFolders?.length) return;
let cancelled = false;
(async () => {
const pending = encryptedFolders.filter((folder) => {
if (!folder?.id || !folder?.name) return false;
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
return !looksLikeCipherString(String(folder.name));
});
if (!pending.length) return;
for (const folder of pending) {
try {
await updateFolder(authedFetch, session, folder.id, String(folder.name));
migratedPlainFolderIdsRef.current.add(folder.id);
} catch {
// keep silent; web still supports plaintext fallback display
}
}
if (!cancelled) await refetchVaultCoreData();
})();
return () => {
cancelled = true;
};
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, authedFetch]);
async function refreshVaultSilently() { async function refreshVaultSilently() {
if (pendingVaultCoreRefreshRef.current) { if (pendingVaultCoreRefreshRef.current) {
await pendingVaultCoreRefreshRef.current; await pendingVaultCoreRefreshRef.current;
@@ -1107,6 +1073,7 @@ export default function App() {
refetchSends: sendsQuery.refetch, refetchSends: sendsQuery.refetch,
onNotify: pushToast, onNotify: pushToast,
patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedCiphers: setDecryptedCiphers,
patchDecryptedFolders: setDecryptedFolders,
}); });
const accountSecurityActions = useAccountSecurityActions({ const accountSecurityActions = useAccountSecurityActions({
authedFetch, authedFetch,
@@ -1203,9 +1170,9 @@ export default function App() {
decryptedCiphers, decryptedCiphers,
decryptedFolders, decryptedFolders,
decryptedSends, decryptedSends,
ciphersLoading: vaultCoreQuery.isFetching, ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
foldersLoading: vaultCoreQuery.isFetching, foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
sendsLoading: sendsQuery.isFetching, sendsLoading: sendsQuery.isFetching && !sendsQuery.data,
users: usersQuery.data || [], users: usersQuery.data || [],
invites: invitesQuery.data || [], invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled, totpEnabled: !!totpStatusQuery.data?.enabled,
+33 -31
View File
@@ -3,13 +3,13 @@ import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import VaultPage from '@/components/VaultPage';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage')); const SendsPage = lazy(() => import('@/components/SendsPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage'));
@@ -181,36 +181,38 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Suspense> </Suspense>
</Route> </Route>
<Route path="/vault"> <Route path="/vault">
<VaultPage <Suspense fallback={<RouteContentFallback />}>
ciphers={props.decryptedCiphers} <VaultPage
folders={props.decryptedFolders} ciphers={props.decryptedCiphers}
loading={props.ciphersLoading || props.foldersLoading} folders={props.decryptedFolders}
emailForReprompt={props.profile?.email || props.session?.email || ''} loading={props.ciphersLoading || props.foldersLoading}
onRefresh={props.onRefreshVault} emailForReprompt={props.profile?.email || props.session?.email || ''}
onCreate={props.onCreateVaultItem} onRefresh={props.onRefreshVault}
onUpdate={props.onUpdateVaultItem} onCreate={props.onCreateVaultItem}
onDelete={props.onDeleteVaultItem} onUpdate={props.onUpdateVaultItem}
onArchive={props.onArchiveVaultItem} onDelete={props.onDeleteVaultItem}
onUnarchive={props.onUnarchiveVaultItem} onArchive={props.onArchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems} onUnarchive={props.onUnarchiveVaultItem}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkDelete={props.onBulkDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems} onBulkRestore={props.onBulkRestoreVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems} onBulkArchive={props.onBulkArchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems} onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword} onBulkMove={props.onBulkMoveVaultItems}
onNotify={props.onNotify} onVerifyMasterPassword={props.onVerifyMasterPassword}
onCreateFolder={props.onCreateFolder} onNotify={props.onNotify}
onRenameFolder={props.onRenameFolder} onCreateFolder={props.onCreateFolder}
onDeleteFolder={props.onDeleteFolder} onRenameFolder={props.onRenameFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders} onDeleteFolder={props.onDeleteFolder}
onDownloadAttachment={props.onDownloadVaultAttachment} onBulkDeleteFolders={props.onBulkDeleteFolders}
downloadingAttachmentKey={props.downloadingAttachmentKey} onDownloadAttachment={props.onDownloadVaultAttachment}
attachmentDownloadPercent={props.attachmentDownloadPercent} downloadingAttachmentKey={props.downloadingAttachmentKey}
uploadingAttachmentName={props.uploadingAttachmentName} attachmentDownloadPercent={props.attachmentDownloadPercent}
attachmentUploadPercent={props.attachmentUploadPercent} uploadingAttachmentName={props.uploadingAttachmentName}
mobileSidebarToggleKey={props.mobileSidebarToggleKey} attachmentUploadPercent={props.attachmentUploadPercent}
/> mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/>
</Suspense>
</Route> </Route>
<Route path={props.settingsAccountRoute}> <Route path={props.settingsAccountRoute}>
{props.profile && ( {props.profile && (
+23
View File
@@ -0,0 +1,23 @@
interface LoadingStateProps {
lines?: number;
compact?: boolean;
card?: boolean;
className?: string;
}
export default function LoadingState(props: LoadingStateProps) {
const lines = Math.max(1, props.lines || 4);
return (
<div className={`${props.card ? 'loading-state-card card' : 'loading-state'}${props.compact ? ' compact' : ''}${props.className ? ` ${props.className}` : ''}`} aria-hidden="true">
{Array.from({ length: lines }, (_, index) => (
<div key={index} className="loading-state-row">
<div className="loading-state-icon shimmer" />
<div className="loading-state-text">
<div className="loading-state-line shimmer" />
<div className="loading-state-line short shimmer" />
</div>
</div>
))}
</div>
);
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact'; import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { Send, SendDraft } from '@/lib/types'; import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -322,6 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
</button> </button>
</div> </div>
<div className="list-panel"> <div className="list-panel">
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
{filteredSends.map((send, index) => ( {filteredSends.map((send, index) => (
<div <div
key={send.id} key={send.id}
@@ -375,7 +377,7 @@ export default function SendsPage(props: SendsPageProps) {
</button> </button>
</div> </div>
))} ))}
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>} {!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
</div> </div>
</section> </section>
@@ -553,6 +555,7 @@ export default function SendsPage(props: SendsPageProps) {
</div> </div>
</div> </div>
)} )}
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
</section> </section>
</div> </div>
); );
+3 -11
View File
@@ -21,7 +21,8 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto'; import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers'; import LoadingState from '@/components/LoadingState';
import { hostFromUri, isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -62,16 +63,6 @@ function firstCipherUri(cipher: Cipher): string {
return ''; return '';
} }
function hostFromUri(uri: string): string {
if (!uri.trim()) return '';
try {
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
return new URL(normalized).hostname || '';
} catch {
return '';
}
}
function TotpListIcon({ cipher }: { cipher: Cipher }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const iconStackRef = useRef<HTMLSpanElement | null>(null); const iconStackRef = useRef<HTMLSpanElement | null>(null);
@@ -447,6 +438,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
className="totp-codes-list" className="totp-codes-list"
style={{ '--totp-columns': String(columnCount) } as Record<string, string>} style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
> >
{!totpItems.length && props.loading && <LoadingState lines={6} />}
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>} {!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}> <SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
+2 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import LoadingState from '@/components/LoadingState';
import VaultDialogs from '@/components/vault/VaultDialogs'; import VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView'; import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor'; import VaultEditor from '@/components/vault/VaultEditor';
@@ -1139,7 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
</div> </div>
)} )}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>} {!isEditing && !selectedCipher && (props.loading ? <LoadingState card lines={5} /> : <div className="empty card">{t('txt_select_an_item')}</div>)}
</section> </section>
</div> </div>
@@ -2,6 +2,7 @@ import type { RefObject } from 'preact';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -234,6 +235,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</div> </div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
{!!props.filteredCiphers.length && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}> <div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher) => ( {props.visibleCiphers.map((cipher) => (
@@ -249,7 +251,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
))} ))}
</div> </div>
)} )}
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>} {!props.loading && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div> </div>
</section> </section>
); );
+73 -11
View File
@@ -68,6 +68,7 @@ interface UseVaultSendActionsOptions {
refetchSends: () => Promise<unknown>; refetchSends: () => Promise<unknown>;
onNotify: Notify; onNotify: Notify;
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
} }
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
@@ -98,6 +99,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
refetchSends, refetchSends,
onNotify, onNotify,
patchDecryptedCiphers, patchDecryptedCiphers,
patchDecryptedFolders,
} = options; } = options;
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
@@ -142,6 +144,44 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id)); patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
} }
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) {
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
if (!idSet.size) return;
patchDecryptedCiphers((prev) => {
let changed = false;
const next: Cipher[] = [];
for (const cipher of prev) {
if (!idSet.has(cipher.id)) {
next.push(cipher);
continue;
}
const updated = updater(cipher);
changed = true;
if (updated) next.push(updated);
}
return changed ? next : prev;
});
}
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
if (!idSet.size) return;
patchDecryptedFolders((prev) => {
let changed = false;
const next: VaultFolder[] = [];
for (const folder of prev) {
if (!idSet.has(folder.id)) {
next.push(folder);
continue;
}
const updated = updater(folder);
changed = true;
if (updated) next.push(updated);
}
return changed ? next : prev;
});
}
const uploadImportedAttachments = async ( const uploadImportedAttachments = async (
attachments: ImportAttachmentFile[], attachments: ImportAttachmentFile[],
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> } idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
@@ -311,7 +351,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkDeleteVaultItems(ids: string[]) { async bulkDeleteVaultItems(ids: string[]) {
try { try {
await bulkDeleteCiphers(authedFetch, ids); await bulkDeleteCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); const deletedDate = new Date().toISOString();
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_deleted_selected_items')); onNotify('success', t('txt_deleted_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
@@ -322,7 +364,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkArchiveVaultItems(ids: string[]) { async bulkArchiveVaultItems(ids: string[]) {
try { try {
await bulkArchiveCiphers(authedFetch, ids); await bulkArchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); const archivedDate = new Date().toISOString();
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_archived_selected_items')); onNotify('success', t('txt_archived_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
@@ -333,7 +377,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkUnarchiveVaultItems(ids: string[]) { async bulkUnarchiveVaultItems(ids: string[]) {
try { try {
await bulkUnarchiveCiphers(authedFetch, ids); await bulkUnarchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_unarchived_selected_items')); onNotify('success', t('txt_unarchived_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
@@ -344,7 +389,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkMoveVaultItems(ids: string[], folderId: string | null) { async bulkMoveVaultItems(ids: string[], folderId: string | null) {
try { try {
await bulkMoveCiphers(authedFetch, ids, folderId); await bulkMoveCiphers(authedFetch, ids, folderId);
await Promise.all([refetchCiphers(), refetchFolders()]); patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_moved_selected_items')); onNotify('success', t('txt_moved_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
@@ -360,8 +406,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await createFolder(authedFetch, session, folderName); const created = await createFolder(authedFetch, session, folderName);
await refetchFolders(); patchDecryptedFolders((prev) => [
{
id: created.id,
name: created.name || folderName,
decName: folderName,
},
...prev,
]);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_created')); onNotify('success', t('txt_folder_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
@@ -377,7 +431,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
await deleteFolder(authedFetch, id); await deleteFolder(authedFetch, id);
await Promise.all([refetchCiphers(), refetchFolders()]); patchFolderBatch([id], () => null);
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_deleted')); onNotify('success', t('txt_folder_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
@@ -399,7 +455,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await updateFolder(authedFetch, session, id, nextName); await updateFolder(authedFetch, session, id, nextName);
await refetchFolders(); patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_updated')); onNotify('success', t('txt_folder_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
@@ -410,7 +467,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkRestoreVaultItems(ids: string[]) { async bulkRestoreVaultItems(ids: string[]) {
try { try {
await bulkRestoreCiphers(authedFetch, ids); await bulkRestoreCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_restored_selected_items')); onNotify('success', t('txt_restored_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
@@ -421,7 +479,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkPermanentDeleteVaultItems(ids: string[]) { async bulkPermanentDeleteVaultItems(ids: string[]) {
try { try {
await bulkPermanentDeleteCiphers(authedFetch, ids); await bulkPermanentDeleteCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); patchCipherBatch(ids, () => null);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_deleted_selected_items_permanently')); onNotify('success', t('txt_deleted_selected_items_permanently'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
@@ -434,7 +493,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
if (!ids.length) return; if (!ids.length) return;
try { try {
await bulkDeleteFolders(authedFetch, ids); await bulkDeleteFolders(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); const removedIds = new Set(ids);
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folders_deleted')); onNotify('success', t('txt_folders_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
+3 -6
View File
@@ -1,6 +1,6 @@
import { hkdf } from '@/lib/crypto'; import { hkdf } from '@/lib/crypto';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher, VaultDraft } from '@/lib/types'; import type { VaultDraft } from '@/lib/types';
import type { ImportResultSummary } from '@/components/ImportPage'; import type { ImportResultSummary } from '@/components/ImportPage';
const SEND_KEY_SALT = 'bitwarden-send'; const SEND_KEY_SALT = 'bitwarden-send';
@@ -26,7 +26,7 @@ export function looksLikeCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
} }
export function asText(value: unknown): string { function asText(value: unknown): string {
if (value === null || value === undefined) return ''; if (value === null || value === undefined) return '';
return String(value); return String(value);
} }
@@ -106,7 +106,7 @@ export function summarizeImportResult(
}; };
} }
export function buildEmptyImportDraft(type: number): VaultDraft { function buildEmptyImportDraft(type: number): VaultDraft {
return { return {
type, type,
favorite: false, favorite: false,
@@ -279,6 +279,3 @@ export async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) }; return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
} }
export function findCipherById(ciphers: Cipher[], id: string): Cipher | null {
return ciphers.find((cipher) => cipher.id === id) || null;
}
-8
View File
@@ -1,14 +1,6 @@
import { decryptStr, decryptBw } from './crypto'; import { decryptStr, decryptBw } from './crypto';
import type { Cipher } from './types'; import type { Cipher } from './types';
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
async function decryptField( async function decryptField(
value: string | null | undefined, value: string | null | undefined,
enc: Uint8Array, enc: Uint8Array,
-79
View File
@@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2); return JSON.stringify(doc, null, 2);
} }
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
const doc = await buildPlainBitwardenJsonDocument(args);
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
const folderNameById = new Map<string, string>();
for (const folder of folders) {
const id = normalizeString(folder.id);
if (!id) continue;
folderNameById.set(id, normalizeString(folder.name) || '');
}
const header = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'archivedDate',
'login_uri',
'login_username',
'login_password',
'login_totp',
];
const rows: string[][] = [header];
for (const item of items) {
const type = normalizeNumber(item.type, 1);
if (type !== 1 && type !== 2) continue;
const folderId = normalizeString(item.folderId);
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
const fields = Array.isArray(item.fields)
? (item.fields as Array<Record<string, unknown>>)
.map((field) => {
const name = normalizeString(field.name) || '';
const value = normalizeString(field.value) || '';
if (!name && !value) return '';
return `${name}: ${value}`;
})
.filter((line) => !!line)
.join('\n')
: '';
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
const loginUris = login && Array.isArray(login.uris)
? (login.uris as Array<Record<string, unknown>>)
.map((uri) => normalizeString(uri.uri) || '')
.filter((uri) => !!uri)
.join('\n')
: '';
rows.push([
folderName,
item.favorite ? '1' : '',
type === 1 ? 'login' : 'note',
normalizeString(item.name) || '',
normalizeString(item.notes) || '',
fields,
String(normalizeNumber(item.reprompt, 0)),
normalizeString(item.archivedDate) || '',
loginUris,
normalizeString(login?.username) || '',
normalizeString(login?.password) || '',
normalizeString(login?.totp) || '',
]);
}
const escapeCsv = (value: string): string => {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> { export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64); const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64); const userMac = base64ToBytes(args.userMacB64);
+2 -45
View File
@@ -1,13 +1,7 @@
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from './crypto'; import { base64ToBytes, decryptBw, decryptStr } from './crypto';
import { deriveSendKeyParts } from './app-support'; import { deriveSendKeyParts } from './app-support';
import type { Cipher, Folder, Send } from './types'; import type { Cipher, Folder, Send } from './types';
export interface AttachmentRepairTask {
cipherId: string;
attachmentId: string;
metadata: { fileName?: string; key?: string | null };
}
export interface DecryptVaultCoreArgs { export interface DecryptVaultCoreArgs {
folders: Folder[]; folders: Folder[];
ciphers: Cipher[]; ciphers: Cipher[];
@@ -18,7 +12,6 @@ export interface DecryptVaultCoreArgs {
export interface DecryptVaultCoreResult { export interface DecryptVaultCoreResult {
folders: Folder[]; folders: Folder[];
ciphers: Cipher[]; ciphers: Cipher[];
attachmentRepairs: AttachmentRepairTask[];
} }
export interface DecryptSendsArgs { export interface DecryptSendsArgs {
@@ -28,10 +21,6 @@ export interface DecryptSendsArgs {
origin: string; origin: string;
} }
function looksLikeCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean { function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false; if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) { for (let i = 0; i < a.byteLength; i += 1) {
@@ -81,7 +70,6 @@ async function decryptFieldWithSource(
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> { export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
const userEnc = base64ToBytes(args.symEncKeyB64); const userEnc = base64ToBytes(args.symEncKeyB64);
const userMac = base64ToBytes(args.symMacKeyB64); const userMac = base64ToBytes(args.symMacKeyB64);
const attachmentRepairs: AttachmentRepairTask[] = [];
const folders = await Promise.all( const folders = await Promise.all(
args.folders.map(async (folder) => ({ args.folders.map(async (folder) => ({
@@ -195,7 +183,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
if (Array.isArray(cipher.attachments)) { if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all( nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => { cipher.attachments.map(async (attachment) => {
const attachmentId = String(attachment?.id || '').trim();
const fileNameResult = await decryptFieldWithSource( const fileNameResult = await decryptFieldWithSource(
attachment.fileName || '', attachment.fileName || '',
itemEnc, itemEnc,
@@ -204,36 +191,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
userMac, userMac,
!itemUsesUserKey !itemUsesUserKey
); );
const metadata: { fileName?: string; key?: string | null } = {};
if (attachmentId && fileNameResult.source === 'user') {
metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac);
}
const attachmentKey = String(attachment?.key || '').trim();
if (attachmentId && attachmentKey && looksLikeCipherString(attachmentKey) && !itemUsesUserKey) {
try {
await decryptBw(attachmentKey, itemEnc, itemMac);
} catch {
try {
const rawAttachmentKey = await decryptBw(attachmentKey, userEnc, userMac);
if (rawAttachmentKey.length >= 64) {
metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac);
}
} catch {
// Download path still supports legacy format.
}
}
}
if (attachmentId && Object.keys(metadata).length > 0) {
attachmentRepairs.push({
cipherId: cipher.id,
attachmentId,
metadata,
});
}
return { return {
...attachment, ...attachment,
decFileName: fileNameResult.text, decFileName: fileNameResult.text,
@@ -246,7 +203,7 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
}) })
); );
return { folders, ciphers, attachmentRepairs }; return { folders, ciphers };
} }
export async function decryptSends(args: DecryptSendsArgs): Promise<Send[]> { export async function decryptSends(args: DecryptSendsArgs): Promise<Send[]> {
+70
View File
@@ -170,3 +170,73 @@
:root[data-theme='dark'] .tree-btn:hover { :root[data-theme='dark'] .tree-btn:hover {
background: var(--panel-subtle); background: var(--panel-subtle);
} }
.loading-state,
.loading-state-card {
display: grid;
gap: 12px;
}
.loading-state.compact,
.loading-state-card.compact {
gap: 10px;
}
.loading-state-card {
padding: 16px;
}
.loading-state-row {
display: flex;
align-items: center;
gap: 12px;
}
.loading-state-icon {
width: 36px;
height: 36px;
flex: 0 0 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--panel-muted) 78%, transparent);
}
.loading-state-text {
display: grid;
gap: 8px;
flex: 1;
}
.loading-state-line {
height: 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--panel-muted) 80%, transparent);
}
.loading-state-line.short {
width: 42%;
}
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, white 38%, transparent) 48%,
transparent 100%
);
animation: loading-shimmer 1.25s ease-in-out infinite;
}
@keyframes loading-shimmer {
100% {
transform: translateX(100%);
}
}